외교 메시지 응답과 알림 권한 정리

This commit is contained in:
2026-09-04 04:39:00 +00:00
parent 38e516de6d
commit f009c50df5
19 changed files with 225 additions and 45 deletions
+5 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import {
readModelOutboxPayloadToDiplomacyMailboxes,
readModelOutboxPayloadToChanges,
readModelOutboxPayloadToMessageMailboxes,
type ReadModelDomain,
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
'access.general',
'dashboard.global',
'messages.mailbox',
'messages.diplomacyMailbox',
'tournament',
'betting',
]);
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
this.db,
async (payload) => {
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
if (mailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
}
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
const changes = readModelOutboxPayloadToChanges(payload);
+16 -3
View File
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>();
return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
const changes = eventChanges(event);
if (!changes) return false;
const generalId = identity.generalId;
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
return [
changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds,
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
const mailboxes =
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
const diplomacyMailboxes =
event.type === 'messageCreated'
? event.msgType === 'diplomacy'
? [event.mailbox]
: []
: (event.diplomacyMailboxes ?? []);
return viewers.some(
(identity) =>
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
(identity.canReadDiplomacy &&
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
)
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null;
}
+6 -2
View File
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
export const publishRealtimeMessageChanges = async (
redis: RedisConnector['client'],
profileName: string,
mailboxes: readonly number[]
mailboxes: readonly number[],
diplomacyMailboxes: readonly number[] = []
): Promise<void> => {
if (mailboxes.length === 0) return;
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
await publishRealtimeEvent(redis, profileName, {
type: 'messagesChanged',
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
...(diplomacyMailboxes.length > 0
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
: {}),
});
};
+3 -2
View File
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
await sendMessage(store, { ...draft, msgType: 'national' });
}
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
};
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
+16 -4
View File
@@ -76,9 +76,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
return value === true || value === 1 || value === '1';
};
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
const markMessageMailboxes = (
ctx: Pick<GameApiContext, 'changeJournal'>,
mailboxes: Iterable<number>,
msgType?: MessageType
): void => {
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
for (const mailbox of mailboxes) {
ctx.changeJournal?.mark('messages.mailbox', mailbox);
ctx.changeJournal?.mark(domain, mailbox);
}
};
@@ -315,7 +320,11 @@ export const messagesRouter = router({
message.msgType === 'national'
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
markMessageMailboxes(
ctx,
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
message.msgType
);
return { ok: true, deletedIds };
}),
respond: engineAuthedProcedure
@@ -333,6 +342,9 @@ export const messagesRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
}
const action = message.payload.option?.action;
if (message.payload.option?.invalid === true) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
}
if (action === 'scout' || action === 'raiseInvader') {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -620,7 +632,7 @@ export const messagesRouter = router({
: msgType === 'private'
? general.id
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
return { msgType, msgId: result.receiverId };
}),
+18 -4
View File
@@ -38,6 +38,7 @@ import {
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent,
} from './realtime/publicEvent.js';
import { resolveNationPermission } from './router/nation/shared.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
@@ -313,11 +314,24 @@ export const createGameApiServer = async () => {
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true },
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
});
return general
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
: { generalId: null, cityId: null, nationId: null };
if (!general) {
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
}
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
};
let viewerIdentity = await loadViewerIdentity();
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
)
);
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await deleteFixtureOutboxes();
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
});
};
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
await expect(
db.readModelRevision.count({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
})
).resolves.toBe(0);
await expect(
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
)
);
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
});
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
});
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
+19 -2
View File
@@ -368,8 +368,8 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('diplomacy');
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 },
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
]);
});
@@ -837,6 +837,7 @@ describe('messages router missing-flow compatibility', () => {
proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number;
invalid?: boolean;
response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>;
}) => {
@@ -891,6 +892,7 @@ describe('messages router missing-flow compatibility', () => {
text: '외교 제안',
option: {
action,
...(options?.invalid ? { invalid: true } : {}),
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
},
},
@@ -1075,6 +1077,21 @@ describe('messages router missing-flow compatibility', () => {
};
};
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
const setup = buildDiplomaticContext({ invalid: true });
await expect(
setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: true,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.requestCommand).not.toHaveBeenCalled();
});
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
const setup = buildDiplomaticContext();
+38 -1
View File
@@ -9,7 +9,7 @@ import {
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
@@ -198,6 +198,37 @@ describe('public realtime event privacy boundary', () => {
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
const blockedViewer = { ...viewer, canReadDiplomacy: false };
expect(
toPublicRealtimeEvent(
{
type: 'messageCreated',
at: '2026-09-04T00:00:00Z',
mailbox,
msgType: 'diplomacy',
messageId: 1,
senderId: 2,
},
[blockedViewer]
)
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = {
type: 'messagesChanged',
@@ -225,6 +256,12 @@ describe('public realtime event privacy boundary', () => {
viewer
)
).toBe(false);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
viewer
)
).toBe(true);
});
it('merges previous and committed identities across an ownership transition', () => {
@@ -12,6 +12,7 @@ const payload = (
| 'access.general'
| 'dashboard.global'
| 'messages.mailbox'
| 'messages.diplomacyMailbox'
| 'tournament'
| 'betting'
) => ({
@@ -19,7 +20,11 @@ const payload = (
changes: [
[
domain,
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
domain === 'front.general' || domain === 'access.general'
? 7
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
? 9999
: 0,
'1',
],
],
@@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
});
});
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
type: 'messagesChanged',
mailboxes: [],
diplomacyMailboxes: [9999],
});
});
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => {
+7 -1
View File
@@ -1852,6 +1852,7 @@ export const createDatabaseTurnHooks = async (
await persistYearbookSnapshot(prisma, snapshot);
}
const persistedMessageMailboxes: number[] = [];
const persistedDiplomacyMailboxes: number[] = [];
for (const finalization of pendingUnificationFinalizations) {
if (options?.profileName && finalization.profileName !== options.profileName) {
throw new Error(
@@ -1878,7 +1879,11 @@ export const createDatabaseTurnHooks = async (
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
if (draft.msgType === 'diplomacy') {
persistedDiplomacyMailboxes.push(draft.mailbox);
} else {
persistedMessageMailboxes.push(draft.mailbox);
}
return id;
},
},
@@ -1968,6 +1973,7 @@ export const createDatabaseTurnHooks = async (
journal.mark('dashboard.global');
}
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes));
markIds(journal, 'access.general', accessScoreResetGeneralIds);
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
journal.mark('betting');
@@ -266,9 +266,9 @@ onBeforeUnmount(() => {
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<div v-if="hasAction && !invalid" class="message-response">
<button
class="prompt-yes"
class="prompt-yes legacy-button legacy-button--primary"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
@@ -276,7 +276,7 @@ onBeforeUnmount(() => {
수락
</button>
<button
class="prompt-no"
class="prompt-no legacy-button legacy-button--danger"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
@@ -414,17 +414,14 @@ button.msg-target {
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
.message-response .legacy-button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
padding: 2px 8px;
font-size: 12.5px;
cursor: pointer;
}