feat(messaging): implement optimistic updates with retry for failed messages
- 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user