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

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