로그아웃 즉시 처리 · 푸시 토큰 정리 순서

This commit is contained in:
Sn-Kinos
2026-10-06 10:03:42 +09:00
parent 40756d5c86
commit 5269b987e0
4 changed files with 138 additions and 70 deletions
+56 -12
View File
@@ -65,6 +65,7 @@ class PushMessaging {
///
/// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다.
Future<String?> syncToken() async {
await _awaitPendingClear();
final token = await _transport.getToken();
if (token == null || token.isEmpty) return null;
await _saveToken(token);
@@ -111,6 +112,7 @@ class PushMessaging {
/// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이
/// 그 값을 알아야 하고, 화면이 알 이유가 없다.
Future<void> syncTopics(Map<PushTopic, bool> prefs) async {
await _awaitPendingClear();
for (final fandomId in await _resolveFandomIds()) {
final plan = topicPlan(fandomId, prefs);
for (final topic in plan.subscribe) {
@@ -150,25 +152,67 @@ class PushMessaging {
///
/// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨
/// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다.
///
/// **두 단계다.** 토큰 제거는 반환 전에 끝낸다(상한 [tokenRemoveTimeout]) — 이 기기 토큰이
/// 이전 계정 문서에 남으면 다음 사용자가 그 계정의 개인 알림을 받는다. 호출자는 이 Future 가
/// 끝난 뒤 로그아웃한다. 토픽 해지는 기다리지 않고 [_pendingClear] 에 남겨, 다음 로그인의
/// [syncToken] · [syncTopics] 가 끝나기를 기다린 뒤에야 새 구독을 넣는다. 실패는 로그만 남긴다.
Future<void> clearOnSignOut() async {
// 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로
// 로그아웃이 수 초 걸린다.
late final Future<void> run;
run = _unsubscribeAll()
.catchError(
(Object e) => debugPrint('[Push] topic cleanup failed: $e'),
)
.whenComplete(() {
if (identical(_pendingClear, run)) _pendingClear = null;
});
_pendingClear = run;
try {
await _removeToken().timeout(tokenRemoveTimeout);
} on Object catch (e) {
debugPrint('[Push] token removal skipped: $e');
}
}
@visibleForTesting
static Duration tokenRemoveTimeout = const Duration(seconds: 2);
/// 진행 중인 로그아웃 정리. 끝나면 비운다.
Future<void>? _pendingClear;
/// 새 구독 전에 남은 정리를 기다린다. 멈춘 정리가 로그인을 막지 않도록 상한을 둔다.
Future<void> _awaitPendingClear() async {
final pending = _pendingClear;
if (pending == null) return;
await pending.timeout(
pendingClearTimeout,
onTimeout: () => debugPrint('[Push] pending cleanup timed out'),
);
}
@visibleForTesting
static Duration pendingClearTimeout = const Duration(seconds: 10);
Future<void> _unsubscribeAll() async {
// 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로 수 초 걸린다.
final ids = await _resolveFandomIds();
// 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에 구독해 버린다.
_fandomIds = null;
await Future.wait([
for (final fandomId in await _resolveFandomIds())
for (final fandomId in ids)
for (final topic in PushTopic.values)
_transport.unsubscribeFromTopic(topic.topicFor(fandomId)),
]);
}
Future<void> _removeToken() async {
final uid = _backend.uid;
final token = await _transport.getToken();
if (uid != null && token != null && token.isNotEmpty) {
await _backend.db.doc('users/$uid').set({
'fcmTokens': FieldValue.arrayRemove([token]),
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
// 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에
// 구독해 버린다.
_fandomIds = null;
if (uid == null || token == null || token.isEmpty) return;
await _backend.db.doc('users/$uid').set({
'fcmTokens': FieldValue.arrayRemove([token]),
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
Future<void> dispose() async {