generated from ttipo/project-base
feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20
File diff suppressed because one or more lines are too long
@@ -124,7 +124,17 @@ function setup(
|
||||
isDeliverable: jest.fn((sessionId: string, fid: string) =>
|
||||
Promise.resolve(!moved.has(`${sessionId}/${fid}`)),
|
||||
),
|
||||
recordPushed: jest.fn(() => Promise.resolve()),
|
||||
// 표처럼 — 보낸 기기의 last_pushed_at 을 그 N 을 센 때로 (따라잡기 뒤 첫 보내기가 이것을 본다)
|
||||
recordPushed: jest.fn((sessionIds: readonly string[], at: Date) => {
|
||||
for (const list of Object.values(targets)) {
|
||||
list.forEach((entry, index) => {
|
||||
if (sessionIds.includes(entry.sessionId)) {
|
||||
list[index] = { ...entry, lastPushedAt: at };
|
||||
}
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
forgetDevice: jest.fn(() => Promise.resolve()),
|
||||
onChanged: jest.fn((next: (user: string) => void) => {
|
||||
listener = next;
|
||||
@@ -553,7 +563,8 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
]);
|
||||
|
||||
current.unread[ARA].push(NOW);
|
||||
// 따라잡기가 센 다음 초에 받은 메일 — 간격 끝 보내기는 따라잡기 뒤 새로 받은 메일이 있을 때만이다(리뷰 N1)
|
||||
current.unread[ARA].push(NOW + 1_000);
|
||||
current.newMail(ARA);
|
||||
await flush();
|
||||
expect(current.sent).toHaveLength(1);
|
||||
@@ -564,6 +575,70 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N
|
||||
]);
|
||||
});
|
||||
|
||||
it('붙잡은 직후 따라잡기가 세는 사이 그 메일의 새 메일 신호가 오면 한 번만 울린다 — 따라잡기는 건너뛰고 신호 쪽이 그때의 N 으로 (리뷰 N1)', async () => {
|
||||
current = setup();
|
||||
// 붙잡는 순간에 막 들어온 메일 — 따라잡기 창 안 · 본 뒤
|
||||
current.unread[ARA] = [NOW - 1_000];
|
||||
const counting =
|
||||
current.provider.countInboxUnreadSince.getMockImplementation();
|
||||
if (!counting) throw new Error('세기 대역이 있어야 한다');
|
||||
let open!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
// 따라잡기의 세기만 문이 열릴 때까지 붙잡는다
|
||||
current.provider.countInboxUnreadSince.mockImplementationOnce(
|
||||
async (user: string, since: readonly Date[]) => {
|
||||
await gate;
|
||||
return counting(user, since);
|
||||
},
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.holds).toHaveLength(1);
|
||||
|
||||
// 세기가 엔진에서 돌아오기 전에 그 메일의 신호가 온다(배달 → 신호 0.8초 남짓)
|
||||
current.newMail(ARA);
|
||||
open();
|
||||
await flush();
|
||||
expect(current.sent).toEqual([
|
||||
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
|
||||
]);
|
||||
|
||||
// 간격이 지나도 더 울리지 않는다
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('따라잡기로 보낸 뒤 그 메일의 신호가 늦게 오면 간격 끝에 다시 보내지 않는다 — 그사이 새로 받은 메일이 있을 때만, 보낸 때를 표에 적지 못해도 (리뷰 N1)', async () => {
|
||||
current = setup();
|
||||
current.unread[ARA] = [NOW - 1_000];
|
||||
// 표의 last_pushed_at 이 바뀌지 않은 것처럼 — 따라잡기가 보낸 기기 · 센 때는 서비스가 메모리에 둔다
|
||||
current.registrations.recordPushed.mockImplementation(() =>
|
||||
Promise.resolve(),
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.sent).toEqual([
|
||||
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
|
||||
]);
|
||||
|
||||
// 따라잡기가 센 메일의 신호가 따라잡기 뒤에 온다
|
||||
jest.setSystemTime(NOW + 800);
|
||||
current.newMail(ARA);
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent).toHaveLength(1);
|
||||
|
||||
// 그 뒤의 신호는 보통대로 — 새로 받은 메일이 있으면 (건너뛴 보내기도 간격에 들어) 다음 간격 끝에 그때의 N 으로
|
||||
current.unread[ARA].push(Date.now());
|
||||
current.newMail(ARA);
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ kind: 'new-mail', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('보내기 직전 그 휴대폰이 다른 세션으로 옮겨 갔으면(isDeliverable false) 그 기기에는 보내지 않는다 — 보낸 때도 적지 않는다 (리뷰 L1)', async () => {
|
||||
const env = await start({
|
||||
targets: { [ARA]: [target('phone'), target('moved')] },
|
||||
|
||||
@@ -60,6 +60,20 @@ interface Pacing {
|
||||
lastFlushAt: number | null;
|
||||
/** 간격 끝에 보낼 차례 */
|
||||
timer: NodeJS.Timeout | null;
|
||||
/**
|
||||
* 따라잡기가 보냈고 그 뒤 신호 보내기가 아직 없다 — 다음 보내기 한 번은 새로 받은 메일이 있는 기기에만(리뷰 N1). 없으면 null.
|
||||
* 따라잡기가 센 메일의 새 메일 신호가 뒤늦게 와서 같은 메일로 한 번 더 울리지 않게
|
||||
*/
|
||||
afterCatchUp: CatchUpPushed | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 따라잡기가 보낸 기기(세션)와 그 N 을 센 때 — 메모리에 둔다(표의 last_pushed_at 저장이 실패해도 같은 메일로 다시 울리지 않게).
|
||||
* 보내지 못한 기기는 넣지 않는다(그 기기에는 그 메일이 아직 가지 않았다)
|
||||
*/
|
||||
interface CatchUpPushed {
|
||||
readonly sessions: ReadonlySet<string>;
|
||||
readonly countedAt: Date;
|
||||
}
|
||||
|
||||
/** 붙잡기에 실패한 직원 — 다음 시도 */
|
||||
@@ -110,7 +124,8 @@ const RECEIVED_AT_RESOLUTION_MS = 1_000;
|
||||
* 그 시각의 다음 초(ms) — 따라잡기가 "새로 온 것"을 볼 시작. 없으면 0.
|
||||
* 세기는 그 초부터 같은 초를 넣으므로(MailProvider.countInboxUnreadSince — N 을 덜 세지 않게) 그대로면 그 초에 받은 메일
|
||||
* — 그 알림의 N 에 이미 들었거나(last_pushed_at) 이미 본(seen_at) — 이 "새로 온 것"으로 다시 세져 따라잡기가 한 번 더 울린다(2026-11-07 리뷰 M1).
|
||||
* 센 · 본 뒤 같은 초에 들어온 메일은 붙잡은 연결이 따로 새 메일 신호를 냈으므로 놓치는 것은 그 1초 안에 서버가 멈춘 경우뿐이다
|
||||
* 센 · 본 뒤 같은 초에 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다.
|
||||
* 따라잡기 뒤 첫 보내기(Pacing.afterCatchUp)도 같은 규칙이라, 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다 — 다음 알림의 N 에는 든다
|
||||
*/
|
||||
function nextSecondOf(time: Date | null): number {
|
||||
if (time === null) return 0;
|
||||
@@ -138,7 +153,8 @@ function timer(callback: () => void, ms: number): NodeJS.Timeout {
|
||||
* N(받은편지함의 안 읽은 메일 가운데 그 기기에서 마지막으로 앱을 본 뒤 받은 수 — 엔진에 센다. 웹에서 읽은 메일은 빠진다) → N 이 1 이상인 기기에 "새 메일 N통".
|
||||
* FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만). 같은 직원의 보내기는 차례로(KeyedQueue)
|
||||
* - **따라잡기**: 새로 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있으면
|
||||
* 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at)
|
||||
* 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at). 붙잡은 직후의 메일 하나에 두 번 울리지 않게(리뷰 N1):
|
||||
* 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛰고(신호 쪽이 그때의 N 으로), 따라잡기로 보낸 뒤 첫 신호 보내기는 그 뒤 새로 받은 메일이 있는 기기에만
|
||||
* - 보내는 글에는 개수만 — 새 메일의 보낸 사람 · 제목 · id 는 이 서비스에 오지도 않는다(붙잡은 쪽은 신호만 받는다)
|
||||
* 로그: 보낸 것은 debug(가린 주소 · 기기 수 · N), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. 메일 글 · FID · 토큰은 남기지 않는다
|
||||
*
|
||||
@@ -158,6 +174,11 @@ export class NewMailPushService
|
||||
private wanted = new Set<string>();
|
||||
private readonly retries = new Map<string, HoldRetry>();
|
||||
private readonly pacing = new Map<string, Pacing>();
|
||||
/**
|
||||
* 이번 붙잡기 뒤 새 메일 신호를 받은 직원 — 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다 — 붙잡은 직후의 메일 하나에 두 번 울리지 않게, 리뷰 N1).
|
||||
* 붙잡기를 시작할 때 지운다(그 붙잡기의 연결이 낸 신호만 센다)
|
||||
*/
|
||||
private readonly signaledSinceHold = new Set<string>();
|
||||
private readonly holdGate = new ConcurrencyGate(HOLD_CONCURRENCY);
|
||||
/** 같은 직원의 보내기 · 따라잡기는 차례로 */
|
||||
private readonly sends = new KeyedQueue();
|
||||
@@ -269,6 +290,7 @@ export class NewMailPushService
|
||||
this.retries.clear();
|
||||
for (const release of this.held.values()) release();
|
||||
this.held.clear();
|
||||
this.signaledSinceHold.clear();
|
||||
}
|
||||
|
||||
private isCompany(user: string): boolean {
|
||||
@@ -298,6 +320,8 @@ export class NewMailPushService
|
||||
}
|
||||
|
||||
private async hold(user: string): Promise<void> {
|
||||
// 이 붙잡기의 연결이 낸 신호만 센다 (holder 는 hold 가 끝나기 바로 전에 켜진다 — 그 전에 지운다)
|
||||
this.signaledSinceHold.delete(user);
|
||||
let release: () => void;
|
||||
try {
|
||||
release = await this.events.hold(user, this.holderFor(user));
|
||||
@@ -314,7 +338,7 @@ export class NewMailPushService
|
||||
}
|
||||
this.held.set(user, release);
|
||||
this.recovered(user);
|
||||
// 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이
|
||||
// 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 (그사이 신호가 오면 건너뛴다 — catchUp)
|
||||
void this.sends.run(user, () => this.catchUp(user));
|
||||
}
|
||||
|
||||
@@ -332,6 +356,7 @@ export class NewMailPushService
|
||||
*/
|
||||
private onHoldEnded(user: string, cause: MailEventsHoldEnd): void {
|
||||
this.held.delete(user);
|
||||
this.signaledSinceHold.delete(user);
|
||||
this.clearPacing(user);
|
||||
if (this.stopping || !this.wanted.has(user)) return;
|
||||
this.scheduleRetry(
|
||||
@@ -350,6 +375,7 @@ export class NewMailPushService
|
||||
private release(user: string): void {
|
||||
const release = this.held.get(user);
|
||||
this.held.delete(user);
|
||||
this.signaledSinceHold.delete(user);
|
||||
release?.();
|
||||
this.clearPacing(user);
|
||||
}
|
||||
@@ -419,7 +445,7 @@ export class NewMailPushService
|
||||
private pacingOf(user: string): Pacing {
|
||||
let pacing = this.pacing.get(user);
|
||||
if (!pacing) {
|
||||
pacing = { lastFlushAt: null, timer: null };
|
||||
pacing = { lastFlushAt: null, timer: null, afterCatchUp: null };
|
||||
this.pacing.set(user, pacing);
|
||||
}
|
||||
return pacing;
|
||||
@@ -428,6 +454,7 @@ export class NewMailPushService
|
||||
/** 새 메일 신호 — 첫 알림은 바로, 간격 안이면 끝에 한 번 (이미 기다리는 차례가 있으면 그것이 그때의 N 으로 보낸다) */
|
||||
private onNewMail(user: string): void {
|
||||
if (this.stopping) return;
|
||||
this.signaledSinceHold.add(user);
|
||||
const pacing = this.pacingOf(user);
|
||||
if (pacing.timer) return;
|
||||
const wait =
|
||||
@@ -446,19 +473,30 @@ export class NewMailPushService
|
||||
|
||||
private flush(user: string, pacing: Pacing): void {
|
||||
pacing.lastFlushAt = Date.now();
|
||||
void this.sends.run(user, () => this.pushNewMail(user));
|
||||
const afterCatchUp = pacing.afterCatchUp;
|
||||
pacing.afterCatchUp = null;
|
||||
void this.sends.run(user, () => this.pushNewMail(user, afterCatchUp));
|
||||
}
|
||||
|
||||
/** 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다) */
|
||||
private async pushNewMail(user: string): Promise<void> {
|
||||
/**
|
||||
* 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다).
|
||||
* 따라잡기 뒤 첫 보내기(`afterCatchUp`)면 따라잡기처럼 새로 온 것이 있는 기기에만 — 따라잡기가 보낸 기기는 그 따라잡기가 센 초 다음부터 본다.
|
||||
* 따라잡기가 센 메일의 신호가 뒤늦게 와서 간격 끝에 같은 메일로 한 번 더 울리지 않게(리뷰 N1)
|
||||
*/
|
||||
private async pushNewMail(
|
||||
user: string,
|
||||
afterCatchUp: CatchUpPushed | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const targets = await this.registrations.targetsOf(user);
|
||||
if (targets.length === 0) return;
|
||||
const countedAt = new Date();
|
||||
const counts = await this.provider.countInboxUnreadSince(
|
||||
user,
|
||||
targets.map((target) => target.seenAt),
|
||||
);
|
||||
const counts = afterCatchUp
|
||||
? await this.freshCounts(user, targets, 0, afterCatchUp)
|
||||
: await this.provider.countInboxUnreadSince(
|
||||
user,
|
||||
targets.map((target) => target.seenAt),
|
||||
);
|
||||
await this.deliver(user, targets, counts, countedAt);
|
||||
} catch (error) {
|
||||
this.reportFailure(user, error);
|
||||
@@ -473,44 +511,82 @@ export class NewMailPushService
|
||||
*/
|
||||
private async catchUp(user: string): Promise<void> {
|
||||
try {
|
||||
// 붙잡은 뒤 새 메일 신호를 이미 받았다 — 신호 쪽이 그때의 N 으로 보낸다(같은 메일로 두 번 울리지 않게 — 리뷰 N1)
|
||||
if (this.signaledSinceHold.has(user)) return;
|
||||
const targets = await this.registrations.targetsOf(user);
|
||||
if (targets.length === 0) return;
|
||||
const countedAt = new Date();
|
||||
const windowStart = countedAt.getTime() - CATCH_UP_WINDOW_MS;
|
||||
const freshSince = targets.map((target) =>
|
||||
Math.max(
|
||||
nextSecondOf(target.seenAt),
|
||||
nextSecondOf(target.lastPushedAt),
|
||||
windowStart,
|
||||
),
|
||||
);
|
||||
const times = [
|
||||
...new Set([
|
||||
...freshSince,
|
||||
...targets.map((target) => target.seenAt.getTime()),
|
||||
]),
|
||||
];
|
||||
const counted = await this.provider.countInboxUnreadSince(
|
||||
const counts = await this.freshCounts(
|
||||
user,
|
||||
times.map((time) => new Date(time)),
|
||||
);
|
||||
const countAt = new Map(
|
||||
times.map((time, index) => [time, counted[index] ?? 0]),
|
||||
);
|
||||
const counts = targets.map((target, index) =>
|
||||
(countAt.get(freshSince[index]) ?? 0) > 0
|
||||
? (countAt.get(target.seenAt.getTime()) ?? 0)
|
||||
: 0,
|
||||
targets,
|
||||
countedAt.getTime() - CATCH_UP_WINDOW_MS,
|
||||
);
|
||||
// 세는 사이 신호가 왔다 — 위와 같다
|
||||
if (this.signaledSinceHold.has(user)) return;
|
||||
if (counts.every((count) => count < 1)) return;
|
||||
// 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번
|
||||
this.pacingOf(user).lastFlushAt = Date.now();
|
||||
await this.deliver(user, targets, counts, countedAt);
|
||||
// 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번, 그 보내기는 이 따라잡기가 센 뒤 새로 받은 메일이 있는 기기에만.
|
||||
// 보내는 동안의 신호도 그 규칙을 타게 보낼 기기 모두로 먼저 적고, 보낸 뒤 실제로 보낸 기기로 좁힌다(그사이 보내기가 가져갔으면 그대로)
|
||||
const pacing = this.pacingOf(user);
|
||||
pacing.lastFlushAt = Date.now();
|
||||
const planned: CatchUpPushed = {
|
||||
sessions: new Set(
|
||||
targets
|
||||
.filter((_, index) => (counts[index] ?? 0) >= 1)
|
||||
.map((target) => target.sessionId),
|
||||
),
|
||||
countedAt,
|
||||
};
|
||||
pacing.afterCatchUp = planned;
|
||||
const pushed = await this.deliver(user, targets, counts, countedAt);
|
||||
if (this.pacing.get(user) === pacing && pacing.afterCatchUp === planned) {
|
||||
pacing.afterCatchUp =
|
||||
pushed.length > 0 ? { sessions: new Set(pushed), countedAt } : null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.reportFailure(user, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기마다 N — 단 "새로 온 것"(본 때 · 마지막으로 보낸 때의 다음 초부터, `windowStart` 뒤 — 따라잡기가 보낸 기기는 그 센 때의 다음 초부터도)이
|
||||
* 없는 기기는 0 (따라잡기 · 따라잡기 뒤 첫 보내기). 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만)
|
||||
*/
|
||||
private async freshCounts(
|
||||
user: string,
|
||||
targets: readonly PushTarget[],
|
||||
windowStart: number,
|
||||
afterCatchUp: CatchUpPushed | null = null,
|
||||
): Promise<number[]> {
|
||||
const freshSince = targets.map((target) =>
|
||||
Math.max(
|
||||
nextSecondOf(target.seenAt),
|
||||
nextSecondOf(target.lastPushedAt),
|
||||
afterCatchUp?.sessions.has(target.sessionId)
|
||||
? nextSecondOf(afterCatchUp.countedAt)
|
||||
: 0,
|
||||
windowStart,
|
||||
),
|
||||
);
|
||||
const times = [
|
||||
...new Set([
|
||||
...freshSince,
|
||||
...targets.map((target) => target.seenAt.getTime()),
|
||||
]),
|
||||
];
|
||||
const counted = await this.provider.countInboxUnreadSince(
|
||||
user,
|
||||
times.map((time) => new Date(time)),
|
||||
);
|
||||
const countAt = new Map(
|
||||
times.map((time, index) => [time, counted[index] ?? 0]),
|
||||
);
|
||||
return targets.map((target, index) =>
|
||||
(countAt.get(freshSince[index]) ?? 0) > 0
|
||||
? (countAt.get(target.seenAt.getTime()) ?? 0)
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* N 이 1 이상인 기기에 보낸다 — 보낸 기기는 그 N 을 센 때를 적고, 받지 않는 기기는 등록을 지운다.
|
||||
* 보내기 직전(다시 하기마다) 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는지 다시 본다 — 그사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다
|
||||
@@ -520,7 +596,7 @@ export class NewMailPushService
|
||||
targets: readonly PushTarget[],
|
||||
counts: readonly number[],
|
||||
countedAt: Date,
|
||||
): Promise<void> {
|
||||
): Promise<string[]> {
|
||||
const pushed: string[] = [];
|
||||
const pushedCounts: number[] = [];
|
||||
await Promise.all(
|
||||
@@ -545,11 +621,12 @@ export class NewMailPushService
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (pushed.length === 0) return;
|
||||
if (pushed.length === 0) return pushed;
|
||||
await this.registrations.recordPushed(pushed, countedAt);
|
||||
this.logger.debug(
|
||||
`앱 알림: user=${maskEmail(user)}, 기기 ${pushed.length}, N ${pushedCounts.join('·')}`,
|
||||
);
|
||||
return pushed;
|
||||
}
|
||||
|
||||
private async forget(user: string, target: PushTarget): Promise<void> {
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('fcmMessageOf (앱 알림의 FCM 메시지 — 계약 1절 "푸시 모
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('로그인이 저절로 끝남 — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 초안 글, 개수 칸 없음', () => {
|
||||
it('세션 끝 알림(SESSION_ENDED_NOTICE 의 까닭) — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 확정 글(push-text.ts), 개수 칸 없음', () => {
|
||||
expect(fcmMessageOf(FID, { kind: 'session-ended' })).toEqual({
|
||||
fid: FID,
|
||||
notification: {
|
||||
|
||||
@@ -67,7 +67,7 @@ export function fcmMessageOf(fid: string, notice: PushNotice): FcmMessage {
|
||||
},
|
||||
};
|
||||
}
|
||||
// 로그인이 저절로 끝났다 — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위
|
||||
// 그 휴대폰을 건드리지 않았는데 앱 세션이 끝났다(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위
|
||||
return {
|
||||
fid,
|
||||
notification: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
export const PUSH_SENDER = Symbol('PUSH_SENDER');
|
||||
|
||||
/** 보낼 알림 — 새 메일 N통 · 로그인이 저절로 끝남 */
|
||||
/** 보낼 알림 — 새 메일 N통 · 그 휴대폰을 건드리지 않았는데 앱 세션이 끝남(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) */
|
||||
export type PushNotice =
|
||||
| { readonly kind: 'new-mail'; readonly count: number }
|
||||
| { readonly kind: 'session-ended' };
|
||||
|
||||
@@ -14,7 +14,7 @@ export function newMailTitle(count: number): string {
|
||||
|
||||
/**
|
||||
* 그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝났을 때(저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지)
|
||||
* 그 휴대폰에 한 번 보내는 알림 — 초안이다(보드 `App1Push` ⑦ 에서 확정되면 바꾼다). 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳.
|
||||
* 그 휴대폰에 한 번 보내는 알림 — 글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로다. 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳.
|
||||
* 직원 · 운영자가 끊은 세션 · 계정을 쓸 수 없는 세션 · 제한된 세션에는 보내지 않는다
|
||||
*/
|
||||
export const SESSION_ENDED_TEXT = {
|
||||
|
||||
@@ -196,6 +196,9 @@
|
||||
- **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 10분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 안 읽은 메일이 있으면 그 기기에 그때의 N 으로 한 번 — 이미 알린 · 이미 본 메일로는 다시 울리지 않는다.
|
||||
"보낸 뒤 · 본 뒤"는 `last_pushed_at`(그 알림의 N 을 센 때) · `seen_at` 의 **다음 초부터**다 — 받은 시각은 초라 그 초에 받은 메일은 그 알림에 이미 들었거나 이미 보았다고 본다(2026-11-07 리뷰). N 은 위처럼 `seen_at` 의 초부터 센다.
|
||||
그 초에 센 · 본 뒤 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. 따라잡기로 보내도 30초 간격에 든다.
|
||||
**붙잡은 직후의 메일 하나에는 한 번만 울린다**(2026-11-08 리뷰): 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다).
|
||||
따라잡기로 보낸 뒤 첫 신호 보내기는 따라잡기와 같은 규칙(`seen_at` · `last_pushed_at` 의 다음 초부터)으로 새로 받은 메일이 있는 기기에만 — 따라잡기가 센 메일의 신호가 뒤늦게 와도 간격 끝에 다시 울리지 않는다.
|
||||
그래서 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다(다음 알림의 N 에는 든다).
|
||||
**엔진의 `after` 는 그 초를 넣지 않는다**("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적는다, 2026-11-08 개발 엔진 0.16.27 에서 확인) — 서버는 한 초 앞을 넘겨 같은 초를 센다(`jmap-mail.provider.ts`). 엔진 판을 올리면 다시 본다(같은 초를 넣게 바뀌면 따라잡기가 이미 알린 메일로 한 번 더 울린다).
|
||||
- **보내는 메시지** (FCM v1 `messages:send` 의 `message` — 서버 `modules/push/fcm/fcm-message.ts` 한 곳, 받는 것은 FID 와 개수뿐):
|
||||
```json
|
||||
|
||||
Reference in New Issue
Block a user