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

@@ -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;

View File

@@ -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<CreatePostScreenProps> = (props) => {
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
const [selection, setSelection] = useState({ start: 0, end: 0 });
// 发帖幂等键:每次进入发布态生成一次,重试时复用同一个,避免重复发帖。
// - 同一次「发布」意图(含网络重试)复用同一个 ID
// - 发布成功后立即刷新,便于下一次发布生成新的 ID。
const clientRequestIdRef = React.useRef<string>('');
// 动画值
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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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));

View File

@@ -795,7 +795,7 @@ export const HomeScreen: React.FC = () => {
const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
<View style={{ marginBottom: 2, paddingHorizontal: responsiveGap / 2 }}>
<PostCard
post={item}
variant="grid"
@@ -804,7 +804,7 @@ export const HomeScreen: React.FC = () => {
/>
</View>
);
}, [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}

View File

@@ -1358,21 +1358,15 @@ export const PostDetailScreen: React.FC = () => {
);
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
// 渲染空评论 - 类似图片中的样式
// 渲染空评论 - 使用 Forum 图标
const renderEmptyComments = useCallback(() => (
<View style={styles.emptyCommentsContainer}>
{/* 四个方块图标 */}
<View style={styles.emptyCommentsGrid}>
<View style={styles.emptyCommentsGridItem} />
<View style={styles.emptyCommentsGridItem} />
<View style={styles.emptyCommentsGridItem} />
<View style={[styles.emptyCommentsGridItem, styles.emptyCommentsGridItemActive]} />
</View>
<MaterialCommunityIcons name="forum-outline" size={48} color={colors.text.disabled} />
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
~
</Text>
</View>
), []);
), [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 */}
<View style={styles.expandedToolbar}>
<View style={styles.expandedToolbarLeft}>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={closeComposer}
>
<MaterialCommunityIcons
name="chevron-down"
size={22}
color={colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={handlePickCommentImage}
@@ -2294,30 +2299,14 @@ function createPostDetailStyles(colors: AppColors) {
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
// 空评论状态样式(类似图片中的四个方块样式)
// 空评论状态样式
emptyCommentsContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl * 2.5,
},
emptyCommentsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
width: 48,
height: 48,
marginBottom: spacing.md,
},
emptyCommentsGridItem: {
width: 20,
height: 20,
backgroundColor: colors.background.disabled,
margin: 2,
borderRadius: borderRadius.sm,
},
emptyCommentsGridItemActive: {
backgroundColor: colors.text.hint,
},
emptyCommentsSubtitle: {
marginTop: spacing.md,
fontSize: fontSizes.sm,
textAlign: 'center',
color: colors.text.hint,

View File

@@ -200,6 +200,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleDeleteMessage,
handleReplyMessage,
handleCancelReply,
handleRetrySend,
handleAvatarPress,
handleAvatarLongPress,
handleSelectMention,
@@ -366,6 +367,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
onImagePress={handleImagePress}
onReply={handleReplyMessage}
onReplyPress={handleReplyPreviewPress}
onRetrySend={handleRetrySend}
/>
);
}, [
@@ -386,6 +388,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleImagePress,
handleReplyMessage,
handleReplyPreviewPress,
handleRetrySend,
]);
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);

View File

@@ -216,10 +216,9 @@ export const ChatInput: React.FC<ChatInputProps & {
<TouchableOpacity
style={styles.sendButtonActive}
onPress={onSend}
activeOpacity={0.85}
activeOpacity={0.75}
>
<View style={styles.sendButtonShine} pointerEvents="none" />
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
<Text style={styles.sendButtonText}></Text>
</TouchableOpacity>
) : isComposerBusy && !isDisabled ? (
<TouchableOpacity

View File

@@ -11,6 +11,7 @@ import {
GestureResponderEvent,
StyleSheet,
Dimensions,
ActivityIndicator,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
@@ -49,6 +50,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
onImagePress,
onReply,
onReplyPress,
onRetrySend,
}) => {
const bubbleRef = useRef<View>(null);
const pressPositionRef = useRef({ x: 0, y: 0 });
@@ -93,6 +95,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
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;
@@ -432,17 +436,45 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
{renderMessageContent()}
{/* 私聊模式:显示已读标记 */}
{isLastReadMessage && (
<View style={styles.readStatusContainer}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
{/* 发送状态指示器 */}
{isMe && (
<View style={styles.sendStatusContainer}>
{isPending && (
<View style={styles.sendStatusRow}>
<ActivityIndicator size="small" color="#999" style={styles.sendStatusIndicator} />
<Text style={styles.pendingStatusText}></Text>
</View>
)}
{isFailed && (
// 点击「发送失败」重试:仅对自己发送的失败消息提供重试入口。
// 用 Pressable 而非 TouchableOpacity避免与外层长按手势冲突
// 失败状态本身是终态,重试期间会被替换为 pending。
<TouchableOpacity
style={styles.sendStatusRow}
onPress={() => onRetrySend?.(message)}
disabled={!onRetrySend}
activeOpacity={0.6}
>
<MaterialCommunityIcons
name="alert-circle"
size={14}
color="#FF3B30"
/>
<Text style={styles.failedStatusText}></Text>
</TouchableOpacity>
)}
{!isPending && !isFailed && isLastReadMessage && (
<View style={styles.sendStatusRow}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
</View>
)}
</View>
)}
</View>

View File

@@ -417,6 +417,33 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
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: {
backgroundColor: cardBgColor,
@@ -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,
},
// 输入框

View File

@@ -101,6 +101,8 @@ export interface MessageBubbleProps {
onReply?: (message: GroupMessage) => void;
// 点击回复预览回调(定位到原消息)
onReplyPress?: (messageId: string) => void;
// 点击「发送失败」气泡重试发送(仅对当前用户自己发送的失败消息触发)
onRetrySend?: (message: GroupMessage) => void;
}
/** 输入框中待发送的本地图片(未上传) */

View File

@@ -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,

View File

@@ -45,6 +45,13 @@ class JPushService {
private notificationCallback: JPushCallback | null = null;
private connectResolved = false;
private listenersRegistered = false;
// notificationArrived 去重JPush 自有通道与厂商通道(如小米)可能对
// 同一条消息各弹一次通知,按 messageID 去重,只让回调处理第一次到达。
// 使用 Map<msgID, timestamp> 配合 TTL 清理,避免长时间运行后集合无限增长,
// 也避免「淘汰最早一半」策略误删近期仍在被厂商通道二次到达的消息。
private arrivedMessageTimestamps: Map<string, number> = new Map();
private static readonly ARRIVED_DEDUP_TTL_MS = 5 * 60 * 1000; // 5 分钟
private static readonly ARRIVED_DEDUP_MAX = 500; // 触发清理的容量上限
async initialize(): Promise<boolean> {
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();
}
}

View File

@@ -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<PostResponse>('/posts', data);
return response.data;
}
// 更新帖子
async updatePost(postId: string, data: UpdatePostRequest): Promise<Post | null> {
try {

View File

@@ -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;
}

View File

@@ -220,11 +220,18 @@ class MessageManager {
async sendMessage(
conversationId: string,
segments: MessageSegment[],
options?: { replyToId?: string }
options?: { replyToId?: string; detailType?: 'private' | 'group' }
): Promise<MessageResponse | null> {
return this.sendService.sendMessage(conversationId, segments, options);
}
async retrySendMessage(
conversationId: string,
failedMessage: MessageResponse
): Promise<MessageResponse | null> {
return this.sendService.retrySendMessage(conversationId, failedMessage);
}
async markAsRead(conversationId: string, seq: number): Promise<void> {
return this.readReceiptManager.markAsRead(conversationId, seq);
}

View File

@@ -228,7 +228,8 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
// ==================== useSendMessage - 发送消息 ====================
interface UseSendMessageReturn {
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
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<MessageResponse | null> => {
async (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }): Promise<MessageResponse | null> => {
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<MessageResponse | null> => {
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<void>;
// 发送消息
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
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,

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,40 +24,77 @@ 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
@@ -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,

View File

@@ -51,7 +51,11 @@ export interface IMessageSendService {
sendMessage(
conversationId: string,
segments: any[],
options?: { replyToId?: string }
options?: { replyToId?: string; detailType?: 'private' | 'group' }
): Promise<MessageResponse | null>;
retrySendMessage(
conversationId: string,
failedMessage: MessageResponse
): Promise<MessageResponse | null>;
}

View File

@@ -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 ====================

View File

@@ -61,6 +61,8 @@ export interface CreateVotePostRequest {
content: string;
}>;
channel_id?: string;
// 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖
client_request_id?: string;
}
// ==================== Privacy & Account ====================

68
src/utils/idempotency.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* 客户端幂等键工具
*
* 用于发帖、发评论等"创建型"请求,避免因重复点击 / 网络重试导致重复创建。
* 客户端为每次"用户意图"生成一个唯一 IDclient_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 / Webcrypto.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();
}