You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中Appwrite+FCM重复通知问题排查求助

问题描述

我使用由cron job触发的Appwrite函数,通过FCM调度每日通知。通知发送时间正确,但同一设备偶尔会收到重复通知。

已排查项

  • 调度逻辑:调度函数多次调用时会更新现有通知。
  • 消息ID:每个消息ID针对目标(设备)和日期的组合是唯一的。
  • Appwrite控制台:每个设备仅有一个通知目标,且每个目标仅被纳入一个通知调度。
  • 应用端代码:应用处理后台通知的逻辑不会导致重复。

示例截图

重复通知截图

代码片段

调度器Appwrite函数

Future<ResponseModel> setRemindersForUser(
    context, User user, Messaging messaging) async {
  context.log('processing user ${user.name}');
  // Retrieve user's timezone offset
  final timezoneOffset = user.prefs.data['timezone'];

  if (timezoneOffset != null) {
    context.log('retrieved timezone offset: $timezoneOffset');
    // Parse timezone offset
    context.log('Original timezone offset: $timezoneOffset');
    final isOffsetNegative = timezoneOffset.startsWith('-');
    final pureOffsetDuration =
        timezoneOffset.replaceAll('-', '').split('.').first;
    context.log('Pure offset duration: $pureOffsetDuration');
    final offsetHour = int.parse(pureOffsetDuration.split(':')[0]);
    final offsetMin = int.parse(pureOffsetDuration.split(':')[1]);
    final offsetSec = int.parse(pureOffsetDuration.split(':')[2]);
    final offsetDuration = Duration(
      hours: offsetHour,
      minutes: offsetMin,
      seconds: offsetSec,
    );
    context.log('Offset duration: $offsetDuration');

    // Calculate next 9 PM in user's local time
    final now = DateTime.now().toUtc();
    context.log('Current UTC time: $now');
    final userTime = isOffsetNegative
        ? now.subtract(offsetDuration)
        : now.add(offsetDuration);
    context.log('Current user time: $userTime');
    DateTime next9PM =
        DateTime(userTime.year, userTime.month, userTime.day, 21);
    if (userTime.isAfter(next9PM)) {
      next9PM = next9PM.add(Duration(days: 1));
    }
    context.log('Next 9 PM user time: $next9PM');

    // Convert next9PM to UTC
    final next9PMUtc = isOffsetNegative
        ? next9PM.add(offsetDuration)
        : next9PM.subtract(offsetDuration);

    final messageId = _generateMessageId(user, next9PMUtc);
    late Function(
        {required String messageId,
        required String title,
        required String body,
        List<String>? topics,
        List<String>? users,
        List<String>? targets,
        Map? data,
        String? action,
        String? image,
        String? icon,
        String? sound,
        String? color,
        String? tag,
        bool? draft,
        String? scheduledAt}) scheduler;
    try {
      context.log('check existing message');
      // update if message already scheduled
      await messaging.getMessage(messageId: messageId);
      // no error thrown means message exists
      scheduler = messaging.updatePush;
      context.log('found existing message');
    } catch (e) {
      // create if message not scheduled
      context.log('no existing message found');
      scheduler = messaging.createPush;
      context.log('should create new message');
      context.log(e.toString());
    }
    // Schedule push notification
    context.log('scheduling push notification');
    final userPushTargets = user.targets
        .where((element) => element.providerType == "push")
        .toList();
    context.log('user push targets: ${jsonEncode(userPushTargets.map(
          (e) => e.toMap(),
        ).toList())}');
    try {
      final content = dynamicNotifications.random;
      final result = await scheduler(
        messageId: messageId,
        title: content.$1,
        body: content.$2,
        scheduledAt: next9PMUtc.toIso8601String(),
        targets: userPushTargets
            .map(
              (e) => e.$id,
            )
            .toList(),
      );
      context
          .log('scheduled push notification!: $messageId - ${result.toMap()}');
      return ResponseModel.success(null);
    } catch (e) {
      context.log(e.toString());
      return ResponseModel.failed(message: e.toString());
    }
  }
  return ResponseModel.failed(message: 'Timezone offset not found');
}

Flutter应用通知处理代码

static Future<bool> initialize() async {
  try {
    final result = await FirebaseMessaging.instance.requestPermission();
    if (Platform.isAndroid) {
      await localNotifications
          .resolvePlatformSpecificImplementation<
              AndroidFlutterLocalNotificationsPlugin>()
          ?.createNotificationChannel(reminderChannel);
    }
    await localNotifications.initialize(
      const InitializationSettings(
        android: AndroidInitializationSettings('@mipmap/ic_launcher'),
        iOS: DarwinInitializationSettings(),
      ),
    );
    _addOnReceiveMessageListener();
    _addTokenRefreshListener();
    return result.authorizationStatus == AuthorizationStatus.authorized;
  } catch (e) {
    log(e.toString());
  }
  return false;
}

static void _addOnReceiveMessageListener() {
  FirebaseMessaging.onMessage.listen((message) {
    if (message.notification != null) {
      _showLocalNotification(
          title: message.notification!.title ?? '',
          message: message.notification!.body ?? '',
          data: jsonEncode(message.data));
    }
  });
}

static void _showLocalNotification(
    {required String title, required String message, String? data}) {
  FlutterLocalNotificationsPlugin()
      .show(generateTimeBasedId(), title, message,
          _details,
          payload: data)
      .then((_) {});
}

static void _addTokenRefreshListener() {
   FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
      // updating fcm token on appwrite
   });
}

可能的原因与解决建议

1. Cron任务并发竞态问题

如果cron触发间隔过短,或者函数执行时间超过间隔,会出现多个实例同时处理同一用户的情况。两个实例可能都检测到消息不存在,进而重复创建调度。

解决:

  • 拉长cron间隔,确保大于函数最大执行时间,避免并发。
  • 加分布式锁:用Appwrite数据库事务,在处理前创建用户ID+日期的锁记录,只有拿到锁的实例才能继续执行。

2. Appwrite消息检查/更新逻辑漏洞

getMessage调用可能因网络波动等原因误判消息不存在,导致重复创建;另外updatePush可能未完全覆盖原有调度。

解决:

  • 改用查询接口:通过用户ID、目标ID、调度时间范围查询已有调度,而非仅依赖messageId的getMessage。
  • 验证更新结果:调用updatePush后,再次查询消息状态,确认原有调度已被覆盖。
  • 检查函数日志,看是否存在getMessage报错但实际消息已存在的情况,针对性处理异常。

3. FCM重复投递

FCM可能因网络重试、设备离线重连重复投递。需确认Appwrite是否将自定义messageId传递给FCM作为幂等标识。

解决:

  • 确认Appwrite的messageId是否映射为FCM的message_id,如果没有,在data字段传递自定义唯一ID,应用端接收时先检查本地存储的已接收ID,避免重复展示。
  • 把_showLocalNotification里的generateTimeBasedId()替换为FCM消息中的自定义唯一ID,这样重复投递的通知会因ID相同被覆盖。

4.时区计算边界错误

手动解析时区偏移量可能在夏令时切换等场景出错,导致next9PMUtc偏差,生成不同messageId进而重复调度。

解决:

  • 使用timezone等专业时区库处理转换,避免手动解析偏移量。
  • 生成messageId时用用户本地日期而非UTC日期,确保同一用户同一本地日期只有一个消息ID。

5.用户目标隐性重复

设备卸载重装后旧令牌未清理,可能导致同一设备对应多个推送目标。

解决:

  • 更新FCM令牌时,删除旧令牌对应的目标记录,确保每个设备只有一个有效目标。
  • 调度前过滤无效/过期目标,比如清理创建时间超过30天的旧目标。

内容的提问来源于stack exchange,提问作者Morteza Mohammadi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 21:47:07