feat: synchronize account icons across game profiles
This commit is contained in:
@@ -21,6 +21,10 @@ GAME_TOKEN_SECRET=change-me
|
||||
# Gateway API
|
||||
GATEWAY_API_HOST=0.0.0.0
|
||||
GATEWAY_API_PORT=13000
|
||||
# Internal projection-only API used by profile Game APIs. Defaults to the local Gateway API port.
|
||||
GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
|
||||
# 실행 중 Game API가 누락된 관리자 아이콘 초기화를 Gateway 원장에서 다시 확인하는 주기입니다(최소 1000ms).
|
||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||
GATEWAY_DB_SCHEMA=public
|
||||
@@ -52,6 +56,8 @@ TRPC_PATH=/trpc
|
||||
# Frontend public URLs
|
||||
# These values are public and become part of the browser bundle.
|
||||
VITE_GATEWAY_WEB_URL=/gateway/
|
||||
# Account-uploaded icons are served by the Gateway and remain a public browser URL.
|
||||
VITE_GATEWAY_USER_ICON_BASE_URL=/gateway/api/user-icons
|
||||
VITE_BOARD_COMMUNITY_URL=/xe/community
|
||||
# Set the remaining links only after their public routes are confirmed.
|
||||
VITE_BOARD_REQUEST_URL=
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { isCanonicalIsoTimestamp, parseAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1';
|
||||
|
||||
export interface AccountIconSource {
|
||||
get(userId: string): Promise<AccountIconProjection | null>;
|
||||
}
|
||||
|
||||
export interface AccountIconResetProjection {
|
||||
userId: string;
|
||||
resetRevision: string;
|
||||
current: AccountIconProjection;
|
||||
}
|
||||
|
||||
export interface AccountIconResetSource {
|
||||
listResets(userIds: string[]): Promise<AccountIconResetProjection[]>;
|
||||
}
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const parseResetProjection = (value: unknown): AccountIconResetProjection => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('invalid account icon reset projection');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Object.keys(record).sort().join(',') !== 'current,resetRevision,userId') {
|
||||
throw new Error('invalid account icon reset projection');
|
||||
}
|
||||
if (
|
||||
typeof record.userId !== 'string' ||
|
||||
typeof record.resetRevision !== 'string' ||
|
||||
!isCanonicalIsoTimestamp(record.resetRevision)
|
||||
) {
|
||||
throw new Error('invalid account icon reset projection');
|
||||
}
|
||||
return {
|
||||
userId: record.userId,
|
||||
resetRevision: record.resetRevision,
|
||||
current: parseAccountIconProjection(record.current),
|
||||
};
|
||||
};
|
||||
|
||||
export class GatewayHttpAccountIconSource implements AccountIconSource, AccountIconResetSource {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
private readonly secret: string,
|
||||
private readonly timeoutMs = 2_000
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async get(userId: string): Promise<AccountIconProjection | null> {
|
||||
const response = await fetch(`${this.baseUrl}/internal/account-icons/${encodeURIComponent(userId)}`, {
|
||||
headers: {
|
||||
'x-sammo-internal-token': deriveInternalToken(this.secret),
|
||||
},
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway account icon request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
return parseAccountIconProjection(await response.json());
|
||||
}
|
||||
|
||||
async listResets(userIds: string[]): Promise<AccountIconResetProjection[]> {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const response = await fetch(`${this.baseUrl}/internal/account-icon-resets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sammo-internal-token': deriveInternalToken(this.secret),
|
||||
},
|
||||
body: JSON.stringify({ userIds }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway account icon reset request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('invalid account icon reset response');
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (Object.keys(record).sort().join(',') !== 'resets' || !Array.isArray(record.resets)) {
|
||||
throw new Error('invalid account icon reset response');
|
||||
}
|
||||
const resets = record.resets.map(parseResetProjection);
|
||||
const requested = new Set(userIds);
|
||||
if (
|
||||
resets.some((reset) => !requested.has(reset.userId)) ||
|
||||
new Set(resets.map((reset) => reset.userId)).size !== resets.length
|
||||
) {
|
||||
throw new Error('invalid account icon reset response');
|
||||
}
|
||||
return resets;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
@@ -35,6 +36,9 @@ export class RedisGatewayFlushSubscriber {
|
||||
};
|
||||
private readonly channel: string;
|
||||
private readonly store: FlushStore;
|
||||
private readonly onFlush?: (event: GatewayUserFlushEvent) => Promise<void> | void;
|
||||
private readonly onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void;
|
||||
private readonly pendingFlushes = new Set<Promise<void>>();
|
||||
|
||||
constructor(
|
||||
client: {
|
||||
@@ -42,11 +46,15 @@ export class RedisGatewayFlushSubscriber {
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
},
|
||||
channel: string,
|
||||
store: FlushStore
|
||||
store: FlushStore,
|
||||
onFlush?: (event: GatewayUserFlushEvent) => Promise<void> | void,
|
||||
onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void
|
||||
) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
this.store = store;
|
||||
this.onFlush = onFlush;
|
||||
this.onFlushError = onFlushError;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -57,6 +65,23 @@ export class RedisGatewayFlushSubscriber {
|
||||
return;
|
||||
}
|
||||
this.store.applyFlush(payload);
|
||||
if (this.onFlush) {
|
||||
let flush: Promise<void>;
|
||||
try {
|
||||
flush = Promise.resolve(this.onFlush(payload));
|
||||
} catch (error) {
|
||||
this.onFlushError?.(error, payload);
|
||||
return;
|
||||
}
|
||||
const tracked = flush
|
||||
.catch((error: unknown) => {
|
||||
this.onFlushError?.(error, payload);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingFlushes.delete(tracked);
|
||||
});
|
||||
this.pendingFlushes.add(tracked);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -65,5 +90,6 @@ export class RedisGatewayFlushSubscriber {
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.client.unsubscribe(this.channel);
|
||||
await Promise.all(this.pendingFlushes);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -1,5 +1,13 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1_000) {
|
||||
throw new Error('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS must be an integer of at least 1000.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -19,6 +27,8 @@ export interface GameApiConfig {
|
||||
auctionTimerRetentionSeconds: number;
|
||||
tournamentPollMs: number;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
accountIconResetReconcileIntervalMs: number;
|
||||
flushChannel: string;
|
||||
}
|
||||
|
||||
@@ -43,7 +53,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
profile,
|
||||
scenario,
|
||||
profileName,
|
||||
daemonRequestTimeoutMs: parseNumberWithFallback(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'),
|
||||
daemonRequestTimeoutMs: parseNumberWithFallback(
|
||||
env.DAEMON_REQUEST_TIMEOUT_MS,
|
||||
5000,
|
||||
'DAEMON_REQUEST_TIMEOUT_MS'
|
||||
),
|
||||
battleSimRequestTimeoutMs: parseNumberWithFallback(
|
||||
env.BATTLE_SIM_REQUEST_TIMEOUT_MS,
|
||||
8000,
|
||||
@@ -54,27 +68,17 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
60,
|
||||
'BATTLE_SIM_RESULT_TTL_SECONDS'
|
||||
),
|
||||
auctionTimerPollMs: parseNumberWithFallback(
|
||||
env.AUCTION_TIMER_POLL_MS,
|
||||
1000,
|
||||
'AUCTION_TIMER_POLL_MS'
|
||||
),
|
||||
auctionTimerResyncMs: parseNumberWithFallback(
|
||||
env.AUCTION_TIMER_RESYNC_MS,
|
||||
300000,
|
||||
'AUCTION_TIMER_RESYNC_MS'
|
||||
),
|
||||
auctionTimerPollMs: parseNumberWithFallback(env.AUCTION_TIMER_POLL_MS, 1000, 'AUCTION_TIMER_POLL_MS'),
|
||||
auctionTimerResyncMs: parseNumberWithFallback(env.AUCTION_TIMER_RESYNC_MS, 300000, 'AUCTION_TIMER_RESYNC_MS'),
|
||||
auctionTimerRetentionSeconds: parseNumberWithFallback(
|
||||
env.AUCTION_TIMER_RETENTION_SECONDS,
|
||||
21600,
|
||||
'AUCTION_TIMER_RETENTION_SECONDS'
|
||||
),
|
||||
tournamentPollMs: parseNumberWithFallback(
|
||||
env.TOURNAMENT_POLL_MS,
|
||||
1000,
|
||||
'TOURNAMENT_POLL_MS'
|
||||
),
|
||||
tournamentPollMs: parseNumberWithFallback(env.TOURNAMENT_POLL_MS, 1000, 'TOURNAMENT_POLL_MS'),
|
||||
gameTokenSecret: secret,
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000',
|
||||
accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS),
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||
import type { BattleSimTransport } from './battleSim/transport.js';
|
||||
import type { FlushStore } from './auth/flushStore.js';
|
||||
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||
import type { AccountIconSource } from './auth/accountIconSource.js';
|
||||
|
||||
export interface GameProfile {
|
||||
id: string;
|
||||
@@ -84,6 +85,7 @@ export interface GameApiContext {
|
||||
accessTokenStore: RedisAccessTokenStore;
|
||||
flushStore: FlushStore;
|
||||
gameTokenSecret: string;
|
||||
accountIconSource?: AccountIconSource;
|
||||
}
|
||||
|
||||
export const createGameApiContext = (options: {
|
||||
@@ -100,6 +102,7 @@ export const createGameApiContext = (options: {
|
||||
accessTokenStore: RedisAccessTokenStore;
|
||||
flushStore: FlushStore;
|
||||
gameTokenSecret: string;
|
||||
accountIconSource?: AccountIconSource;
|
||||
}): GameApiContext => {
|
||||
return {
|
||||
requestId: options.requestId,
|
||||
@@ -116,5 +119,6 @@ export const createGameApiContext = (options: {
|
||||
accessTokenStore: options.accessTokenStore,
|
||||
flushStore: options.flushStore,
|
||||
gameTokenSecret: options.gameTokenSecret,
|
||||
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import { isAfter, isValid, parseISO } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, engineProcedure, router } from '../../trpc.js';
|
||||
import { enqueueProfileIconResetForUser } from '../../services/accountIconSync.js';
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
const parsed = parseISO(value);
|
||||
@@ -21,11 +22,7 @@ const resolveTtlSeconds = (expiresAt: string): number => {
|
||||
return ttl > 0 ? ttl : 0;
|
||||
};
|
||||
|
||||
const verifyGatewayToken = (
|
||||
token: string,
|
||||
profileName: string,
|
||||
secret: string
|
||||
): GameSessionTokenPayload | null => {
|
||||
const verifyGatewayToken = (token: string, profileName: string, secret: string): GameSessionTokenPayload | null => {
|
||||
const payload = decryptGameSessionToken(token, secret);
|
||||
if (!payload) {
|
||||
return null;
|
||||
@@ -52,14 +49,10 @@ export const authRouter = router({
|
||||
}
|
||||
return { userId };
|
||||
}),
|
||||
exchangeGatewayToken: procedure
|
||||
exchangeGatewayToken: engineProcedure
|
||||
.input(z.object({ gatewayToken: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const payload = verifyGatewayToken(
|
||||
input.gatewayToken,
|
||||
ctx.profile.name,
|
||||
ctx.gameTokenSecret
|
||||
);
|
||||
const payload = verifyGatewayToken(input.gatewayToken, ctx.profile.name, ctx.gameTokenSecret);
|
||||
if (!payload) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
@@ -88,6 +81,12 @@ export const authRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.user.profileIconResetAt && payload.user.profileIconResetAt === payload.user.iconUpdatedAt) {
|
||||
// 일반 계정 아이콘 변경은 Ref처럼 사용자가 고른 서버에만 적용한다.
|
||||
// 관리자 reset만 다음 인증 경계에서 durable하게 복구한다.
|
||||
await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt);
|
||||
}
|
||||
|
||||
const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds);
|
||||
if (!used) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
accessEngineAuthedProcedure,
|
||||
accessEngineAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
engineAuthedProcedure,
|
||||
router,
|
||||
} from '../../trpc.js';
|
||||
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveNationNotice } from '../nation/shared.js';
|
||||
|
||||
@@ -169,6 +171,13 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
|
||||
};
|
||||
|
||||
export const generalRouter = router({
|
||||
adjustIcon: engineAuthedProcedure.mutation(({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return adjustAccountIconForUser(ctx, userId);
|
||||
}),
|
||||
me: authedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
@@ -326,12 +335,15 @@ export const generalRouter = router({
|
||||
availableAt: result.availableAt ?? null,
|
||||
};
|
||||
}),
|
||||
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
|
||||
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
|
||||
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')),
|
||||
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
|
||||
requestImmediateAction(ctx, input, 'dieOnPrestart')
|
||||
),
|
||||
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
|
||||
requestImmediateAction(ctx, input, 'buildNationCandidate')
|
||||
),
|
||||
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
|
||||
requestImmediateAction(ctx, input, 'instantRetreat')
|
||||
),
|
||||
vacation: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
||||
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
|
||||
import {
|
||||
ConflictingTurnDaemonCommandError,
|
||||
@@ -465,6 +466,7 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const userId = auth.user.id;
|
||||
const accountIcon = input.pic ? await loadAuthoritativeAccountIcon(ctx, userId) : null;
|
||||
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||
const result = await requestJoinCreateCommand(ctx, {
|
||||
type: 'joinCreateGeneral',
|
||||
@@ -479,8 +481,13 @@ export const joinRouter = router({
|
||||
pic: input.pic,
|
||||
character: input.character,
|
||||
profileId: ctx.profile.id,
|
||||
...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}),
|
||||
...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}),
|
||||
...(accountIcon
|
||||
? {
|
||||
ownerPicture: accountIcon.picture,
|
||||
ownerImageServer: accountIcon.imageServer,
|
||||
ownerIconRevision: accountIcon.revision,
|
||||
}
|
||||
: {}),
|
||||
...(auth.user.canUseGeneralPicture !== undefined
|
||||
? {
|
||||
ownerCanUsePicture: auth.user.canUseGeneralPicture,
|
||||
|
||||
+105
-23
@@ -22,6 +22,10 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
||||
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
||||
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -59,13 +63,32 @@ const resolveAuthFromToken = async (
|
||||
|
||||
export const createGameApiServer = async () => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
await postgres.connect();
|
||||
await redis.connect();
|
||||
try {
|
||||
await redis.connect();
|
||||
} catch (error) {
|
||||
await postgres.disconnect();
|
||||
throw error;
|
||||
}
|
||||
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
|
||||
|
||||
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
||||
const accountIconResetReconciler = new AccountIconResetReconciler(
|
||||
postgres.prisma,
|
||||
accountIconSource,
|
||||
turnDaemon,
|
||||
config.accountIconResetReconcileIntervalMs,
|
||||
(error) => app.log.error({ err: error }, 'account icon reset reconciliation failed')
|
||||
);
|
||||
const battleSim = new RedisBattleSimTransport(redis.client, {
|
||||
keys: buildBattleSimQueueKeys(config.profileName),
|
||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||
@@ -73,21 +96,70 @@ export const createGameApiServer = async () => {
|
||||
});
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
await flushSubscriberClient.connect();
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
||||
await flushSubscriber.start();
|
||||
try {
|
||||
await flushSubscriberClient.connect();
|
||||
} catch (error) {
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
throw error;
|
||||
}
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(
|
||||
flushSubscriberClient,
|
||||
config.flushChannel,
|
||||
flushStore,
|
||||
createAdminProfileIconResetFlushHandler(accountIconSource, turnDaemon),
|
||||
(error, event) => {
|
||||
app.log.error({ err: error, userId: event.userId, reason: event.reason }, 'gateway flush handler failed');
|
||||
}
|
||||
);
|
||||
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
|
||||
const realtimeSubscriberClient = redis.client.duplicate();
|
||||
await realtimeSubscriberClient.connect();
|
||||
try {
|
||||
await realtimeSubscriberClient.connect();
|
||||
} catch (error) {
|
||||
await flushSubscriberClient.quit();
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
throw error;
|
||||
}
|
||||
const realtimeHub = new RedisRealtimeEventHub(realtimeSubscriberClient, buildGameEventChannel(config.profileName));
|
||||
await realtimeHub.start();
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
let flushSubscriberStarted = false;
|
||||
let realtimeHubStarted = false;
|
||||
const closeResources = createBestEffortResourceCloser([
|
||||
{
|
||||
name: 'account-icon-reset-reconciler',
|
||||
run: () => accountIconResetReconciler.stop(),
|
||||
},
|
||||
});
|
||||
{
|
||||
name: 'gateway-flush-subscriber',
|
||||
run: async () => {
|
||||
if (flushSubscriberStarted) await flushSubscriber.stop();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gateway-flush-redis-client',
|
||||
run: async () => {
|
||||
await flushSubscriberClient.quit();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'realtime-redis-client',
|
||||
run: async () => {
|
||||
if (realtimeHubStarted) await realtimeHub.stop();
|
||||
else await realtimeSubscriberClient.quit();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redis',
|
||||
run: () => redis.disconnect(),
|
||||
},
|
||||
{
|
||||
name: 'postgres',
|
||||
run: () => postgres.disconnect(),
|
||||
},
|
||||
]);
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -127,6 +199,7 @@ export const createGameApiServer = async () => {
|
||||
accessTokenStore,
|
||||
flushStore,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
accountIconSource,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -198,15 +271,19 @@ export const createGameApiServer = async () => {
|
||||
app.get('/healthz', async () => ({
|
||||
ok: true,
|
||||
profile: config.profileName,
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
}));
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await flushSubscriber.stop();
|
||||
await flushSubscriberClient.quit();
|
||||
await realtimeHub.stop();
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
});
|
||||
try {
|
||||
await realtimeHub.start();
|
||||
realtimeHubStarted = true;
|
||||
await flushSubscriber.start();
|
||||
flushSubscriberStarted = true;
|
||||
accountIconResetReconciler.start();
|
||||
} catch (error) {
|
||||
await closeResources();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
@@ -216,8 +293,13 @@ export const createGameApiServer = async () => {
|
||||
|
||||
export const runGameApiServer = async (): Promise<void> => {
|
||||
const { app, config } = await createGameApiServer();
|
||||
await app.listen({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
try {
|
||||
await app.listen({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
} catch (error) {
|
||||
await app.close();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { isCanonicalIsoTimestamp, isRecord, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { AccountIconResetProjection, AccountIconResetSource } from '../auth/accountIconSource.js';
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
const MAX_TERMINAL_REQUEUES = 3;
|
||||
|
||||
type GeneralIconState = {
|
||||
id: number;
|
||||
userId: string | null;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
meta: unknown;
|
||||
};
|
||||
|
||||
export type AccountIconResetReconcilerHealth = {
|
||||
running: boolean;
|
||||
lastSuccessAt: string | null;
|
||||
lastErrorAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
const readCurrentRevision = (meta: unknown): string | null => {
|
||||
if (!isRecord(meta)) {
|
||||
return null;
|
||||
}
|
||||
const value = meta.accountIconUpdatedAt;
|
||||
return typeof value === 'string' && isCanonicalIsoTimestamp(value) ? value : null;
|
||||
};
|
||||
|
||||
const projectionForGeneral = (general: GeneralIconState, reset: AccountIconResetProjection): AccountIconProjection => {
|
||||
const currentRevision = readCurrentRevision(general.meta);
|
||||
if (currentRevision) {
|
||||
return {
|
||||
revision: reset.resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Existing installations have no per-General watermark. If the rendered
|
||||
// tuple already equals a post-reset Gateway projection, seed that newer
|
||||
// revision instead of replaying the historical reset over it.
|
||||
if (
|
||||
reset.current.revision > reset.resetRevision &&
|
||||
general.picture === reset.current.picture &&
|
||||
general.imageServer === reset.current.imageServer
|
||||
) {
|
||||
return reset.current;
|
||||
}
|
||||
return {
|
||||
revision: reset.resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const stableJson = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
};
|
||||
|
||||
export class AccountIconResetReconciler {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private lastSuccessAt: string | null = null;
|
||||
private lastErrorAt: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseClient,
|
||||
private readonly source: AccountIconResetSource,
|
||||
private readonly turnDaemon: TurnDaemonTransport,
|
||||
private readonly intervalMs: number,
|
||||
private readonly onError: (error: unknown) => void = () => undefined
|
||||
) {}
|
||||
|
||||
getHealth(): AccountIconResetReconcilerHealth {
|
||||
return {
|
||||
running: this.timer !== null,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastErrorAt: this.lastErrorAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendWithTerminalRecovery(userId: string, projection: AccountIconProjection): Promise<void> {
|
||||
const baseRequestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
const events = await this.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: [{ requestId: baseRequestId }, { requestId: { startsWith: `${baseRequestId}:retry:` } }],
|
||||
},
|
||||
select: {
|
||||
requestId: true,
|
||||
status: true,
|
||||
eventType: true,
|
||||
payload: true,
|
||||
},
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const latest = events.at(-1);
|
||||
if (latest) {
|
||||
const expected = {
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: latest.requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
};
|
||||
if (latest.eventType !== 'adjustGeneralIcon' || stableJson(latest.payload) !== stableJson(expected)) {
|
||||
throw new Error('account icon reset event payload conflicts with the durable journal');
|
||||
}
|
||||
}
|
||||
if (latest?.status === 'PENDING' || latest?.status === 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryCount = events.filter(({ requestId }) => requestId !== baseRequestId).length;
|
||||
if (latest && retryCount >= MAX_TERMINAL_REQUEUES) {
|
||||
throw new Error(`account icon reset exhausted ${MAX_TERMINAL_REQUEUES} terminal retries`);
|
||||
}
|
||||
const requestId = latest ? `${baseRequestId}:retry:${retryCount + 1}` : baseRequestId;
|
||||
await this.turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcilePage(generals: GeneralIconState[]): Promise<Error[]> {
|
||||
const userIds = [...new Set(generals.flatMap((general) => (general.userId ? [general.userId] : [])))];
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const resets = await this.source.listResets(userIds);
|
||||
const resetByUserId = new Map(resets.map((reset) => [reset.userId, reset]));
|
||||
const failures: Error[] = [];
|
||||
|
||||
for (const general of generals) {
|
||||
if (!general.userId) continue;
|
||||
const reset = resetByUserId.get(general.userId);
|
||||
if (!reset) continue;
|
||||
const currentRevision = readCurrentRevision(general.meta);
|
||||
if (currentRevision && currentRevision >= reset.resetRevision) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.sendWithTerminalRecovery(general.userId, projectionForGeneral(general, reset));
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
new Error(`account icon reset failed for user ${general.userId}: ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async reconcileOnce(): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
let cursorId: number | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
const generals: GeneralIconState[] = await this.db.general.findMany({
|
||||
where: {
|
||||
userId: { not: null },
|
||||
npcState: 0,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
take: BATCH_SIZE,
|
||||
...(cursorId === null ? {} : { cursor: { id: cursorId }, skip: 1 }),
|
||||
});
|
||||
failures.push(...(await this.reconcilePage(generals)));
|
||||
if (generals.length < BATCH_SIZE) break;
|
||||
cursorId = generals.at(-1)?.id ?? null;
|
||||
if (cursorId === null) break;
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `${failures.length} account icon reset reconciliation(s) failed`);
|
||||
}
|
||||
this.lastSuccessAt = new Date().toISOString();
|
||||
this.lastErrorAt = null;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.lastErrorAt = new Date().toISOString();
|
||||
this.lastError = errorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
const run = (): void => {
|
||||
if (this.inFlight) {
|
||||
return;
|
||||
}
|
||||
this.inFlight = this.reconcileOnce()
|
||||
.catch((error: unknown) => this.onError(error))
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
};
|
||||
run();
|
||||
this.timer = setInterval(run, this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { isCanonicalIsoTimestamp, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
import { ConflictingTurnDaemonCommandError } from '../daemon/databaseTransport.js';
|
||||
import type { AccountIconSource } from '../auth/accountIconSource.js';
|
||||
import type { GatewayUserFlushEvent } from '../auth/flushStore.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
|
||||
export const loadAuthoritativeAccountIcon = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string
|
||||
): Promise<AccountIconProjection> => {
|
||||
if (!ctx.accountIconSource) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway 계정 아이콘 원장이 구성되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const projection = await ctx.accountIconSource.get(userId);
|
||||
if (!projection) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway에서 계정 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
return projection;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway 계정 아이콘 정보를 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustAccountIconForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string
|
||||
): Promise<{
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}> => {
|
||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
||||
const requestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
try {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
if (result.type !== 'adjustGeneralIcon') {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '턴 데몬이 올바르지 않은 아이콘 적용 결과를 반환했습니다.',
|
||||
});
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({
|
||||
code: result.code,
|
||||
message: result.reason,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
generalId: result.generalId,
|
||||
updated: result.updated,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ConflictingTurnDaemonCommandError ||
|
||||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 접수된 아이콘 적용 요청과 최신 계정 정보가 다릅니다.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const enqueueProfileIconResetForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string,
|
||||
expectedResetRevision: string
|
||||
): Promise<boolean> => {
|
||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
||||
if (
|
||||
projection.revision !== expectedResetRevision ||
|
||||
projection.picture !== 'default.jpg' ||
|
||||
projection.imageServer !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await ctx.turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: `general:adjustIcon:${userId}:${projection.revision}`,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createAdminProfileIconResetFlushHandler =
|
||||
(source: AccountIconSource, turnDaemon: TurnDaemonTransport) =>
|
||||
async (event: GatewayUserFlushEvent): Promise<void> => {
|
||||
if (event.reason !== 'admin-profile-icon-reset') {
|
||||
return;
|
||||
}
|
||||
if (!event.iconRevision || !isCanonicalIsoTimestamp(event.iconRevision)) {
|
||||
return;
|
||||
}
|
||||
const projection = await source.get(event.userId);
|
||||
if (
|
||||
!projection ||
|
||||
projection.revision !== event.iconRevision ||
|
||||
projection.picture !== 'default.jpg' ||
|
||||
projection.imageServer !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: `general:adjustIcon:${event.userId}:${projection.revision}`,
|
||||
userId: event.userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ResourceCleanupStep {
|
||||
name: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종료 단계 하나가 실패해도 나머지 연결을 모두 닫고, 다음 호출에서는 실패한
|
||||
* 단계만 재시도합니다. 동시에 들어온 종료 요청은 같은 실행을 공유합니다.
|
||||
*/
|
||||
export const createBestEffortResourceCloser = (steps: readonly ResourceCleanupStep[]): (() => Promise<void>) => {
|
||||
const completed = new Set<string>();
|
||||
let closing: Promise<void> | null = null;
|
||||
|
||||
return async (): Promise<void> => {
|
||||
if (completed.size === steps.length) return;
|
||||
if (closing) return closing;
|
||||
|
||||
closing = (async () => {
|
||||
const errors: Error[] = [];
|
||||
for (const step of steps) {
|
||||
if (completed.has(step.name)) continue;
|
||||
try {
|
||||
await step.run();
|
||||
completed.add(step.name);
|
||||
} catch (error) {
|
||||
errors.push(new Error(`Failed to close resource: ${step.name}`, { cause: error }));
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'One or more resources failed to close.');
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await closing;
|
||||
} finally {
|
||||
closing = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -107,6 +107,7 @@ export const accessAuthedProcedure: typeof procedure = t.procedure
|
||||
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
|
||||
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
|
||||
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
export const engineProcedure: typeof procedure = t.procedure;
|
||||
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalAccessEndpointMiddleware);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { createAdminProfileIconResetFlushHandler } from '../src/services/accountIconSync.js';
|
||||
|
||||
describe('administrator profile icon reset flush', () => {
|
||||
it('enqueues only the administrator reset projection', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const get = vi.fn(async () => ({
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
}));
|
||||
const handler = createAdminProfileIconResetFlushHandler({ get }, transport);
|
||||
|
||||
await handler({
|
||||
userId: 'user-1',
|
||||
flushedAt: '2026-07-31T09:00:00.000Z',
|
||||
reason: 'account-icon-changed',
|
||||
});
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
|
||||
await handler({
|
||||
userId: 'user-1',
|
||||
flushedAt: '2026-07-31T09:00:00.001Z',
|
||||
reason: 'admin-profile-icon-reset',
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
});
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'general:adjustIcon:user-1:2026-07-31T09:00:00.001Z',
|
||||
userId: 'user-1',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
});
|
||||
|
||||
get.mockResolvedValueOnce({
|
||||
revision: '2026-07-31T09:00:00.002Z',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
await handler({
|
||||
userId: 'user-1',
|
||||
flushedAt: '2026-07-31T09:00:00.003Z',
|
||||
reason: 'admin-profile-icon-reset',
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SystemClock } from '@sammo-ts/common';
|
||||
import {
|
||||
createDatabaseTurnHooks,
|
||||
DatabaseTurnDaemonCommandQueue,
|
||||
EngineStateManager,
|
||||
InMemoryTurnStateStore,
|
||||
InMemoryTurnWorld,
|
||||
loadTurnWorldFromDatabase,
|
||||
TurnDaemonLifecycle,
|
||||
type TurnGeneral,
|
||||
type TurnWorldSnapshot,
|
||||
type TurnWorldState,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js';
|
||||
|
||||
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 991_744;
|
||||
const userId = 'account-icon-reset-reconcile-user';
|
||||
const revision = '2026-07-31T10:00:00.001Z';
|
||||
const requestId = `general:adjustIcon:${userId}:${revision}`;
|
||||
const retryRequestId = `${requestId}:retry:1`;
|
||||
const lifecycleGeneralId = 991_745;
|
||||
const lifecycleWorldId = 991_745;
|
||||
const lifecycleUserId = 'account-icon-reset-lifecycle-user';
|
||||
const lifecycleRevision = '2026-07-31T10:20:00.001Z';
|
||||
const lifecycleRequestId = `general:adjustIcon:${lifecycleUserId}:${lifecycleRevision}`;
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const scenarioConfig: ScenarioConfig = {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'che', unitSet: 'che' },
|
||||
};
|
||||
const scenarioMeta: ScenarioMeta = {
|
||||
title: '계정 아이콘 reset lifecycle 통합',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
};
|
||||
const map: MapDefinition = { id: 'account-icon-reset-lifecycle', name: scenarioMeta.title, cities: [] };
|
||||
const lifecycleState: TurnWorldState = {
|
||||
id: lifecycleWorldId,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||
meta: { killturn: 24, scenarioMeta },
|
||||
};
|
||||
const lifecycleGeneral: TurnGeneral = {
|
||||
id: lifecycleGeneralId,
|
||||
userId: lifecycleUserId,
|
||||
name: '초기화lifecycle장수',
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
turnTime: new Date('2026-07-31T10:30:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T10:10:00.000Z', preserved: 'yes' },
|
||||
penalty: {},
|
||||
officerLevel: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
picture: 'before-lifecycle-reset.png',
|
||||
imageServer: 1,
|
||||
};
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
if (!schema?.endsWith('immediate_action_integration')) {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
};
|
||||
|
||||
integration('account icon reset reconciliation PostgreSQL queue', () => {
|
||||
let db: GamePrismaClient;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedDatabase(databaseUrl!);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
|
||||
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
id: lifecycleWorldId,
|
||||
scenarioCode: 'account-icon-reset-lifecycle',
|
||||
currentYear: lifecycleState.currentYear,
|
||||
currentMonth: lifecycleState.currentMonth,
|
||||
tickSeconds: lifecycleState.tickSeconds,
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '초기화복구장수',
|
||||
turnTime: new Date('2026-07-31T10:10:00.000Z'),
|
||||
picture: 'before-reset.png',
|
||||
imageServer: 1,
|
||||
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T09:00:00.000Z' },
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: lifecycleGeneral.id,
|
||||
userId: lifecycleGeneral.userId,
|
||||
name: lifecycleGeneral.name,
|
||||
turnTime: lifecycleGeneral.turnTime,
|
||||
picture: lifecycleGeneral.picture,
|
||||
imageServer: lifecycleGeneral.imageServer,
|
||||
meta: lifecycleGeneral.meta,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
|
||||
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
|
||||
}
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
it('deduplicates an active durable enqueue and creates one bounded retry after terminal failure', async () => {
|
||||
const source = {
|
||||
listResets: vi.fn(async () => [
|
||||
{
|
||||
userId,
|
||||
resetRevision: revision,
|
||||
current: {
|
||||
revision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
const reconciler = new AccountIconResetReconciler(
|
||||
db,
|
||||
source,
|
||||
new DatabaseTurnDaemonTransport(db, 1_000),
|
||||
30_000
|
||||
);
|
||||
|
||||
await reconciler.reconcileOnce();
|
||||
await reconciler.reconcileOnce();
|
||||
|
||||
await expect(db.inputEvent.findMany({ where: { requestId } })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralIcon',
|
||||
status: 'PENDING',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: revision,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
attempts: 3,
|
||||
error: 'simulated terminal failure',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await reconciler.reconcileOnce();
|
||||
await reconciler.reconcileOnce();
|
||||
|
||||
await expect(
|
||||
db.inputEvent.findMany({
|
||||
where: { requestId: { startsWith: requestId } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
})
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ requestId, status: 'FAILED' }),
|
||||
expect.objectContaining({
|
||||
requestId: retryRequestId,
|
||||
status: 'PENDING',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: retryRequestId,
|
||||
userId,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: revision,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('flows from reconciliation through the durable daemon lifecycle and persists one reset', async () => {
|
||||
const source = {
|
||||
listResets: vi.fn(async () => [
|
||||
{
|
||||
userId: lifecycleUserId,
|
||||
resetRevision: lifecycleRevision,
|
||||
current: {
|
||||
revision: lifecycleRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
const reconciler = new AccountIconResetReconciler(
|
||||
db,
|
||||
source,
|
||||
new DatabaseTurnDaemonTransport(db, 1_000),
|
||||
30_000
|
||||
);
|
||||
await reconciler.reconcileOnce();
|
||||
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [lifecycleGeneral],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig,
|
||||
scenarioMeta,
|
||||
map,
|
||||
};
|
||||
const world = new InMemoryTurnWorld(lifecycleState, snapshot, { schedule });
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await queue.initialize();
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('world', {
|
||||
capture: () => world.captureState(),
|
||||
restore: (captured) => world.restoreState(captured),
|
||||
});
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new SystemClock(),
|
||||
controlQueue: queue,
|
||||
commandResponder: queue,
|
||||
getNextTickTime: () => new Date(Date.now() + 3_600_000),
|
||||
stateStore: new InMemoryTurnStateStore(world),
|
||||
processor: {
|
||||
run: async () => {
|
||||
throw new Error('scheduled turn must not run in account icon lifecycle integration');
|
||||
},
|
||||
},
|
||||
commandHandler: createTurnDaemonCommandHandler({ world }),
|
||||
hooks: hooks.hooks,
|
||||
stateManager,
|
||||
},
|
||||
{
|
||||
profile: 'account-icon-reset-lifecycle',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
const waitForSuccess = async () => {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const event = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
|
||||
if (event?.status === 'SUCCEEDED' && event.lockedBy === null) return event;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const observed = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
|
||||
throw new Error(
|
||||
`Timed out waiting for ${lifecycleRequestId} to succeed: ${JSON.stringify(
|
||||
observed && {
|
||||
status: observed.status,
|
||||
attempts: observed.attempts,
|
||||
error: observed.error,
|
||||
result: observed.result,
|
||||
lockedBy: observed.lockedBy,
|
||||
}
|
||||
)}`
|
||||
);
|
||||
};
|
||||
|
||||
let loop: Promise<void> | undefined;
|
||||
try {
|
||||
loop = lifecycle.start();
|
||||
await expect(waitForSuccess()).resolves.toMatchObject({
|
||||
attempts: 1,
|
||||
result: {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: lifecycleGeneralId,
|
||||
updated: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await lifecycle.stop('account icon reset integration finished');
|
||||
await loop;
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: lifecycleGeneralId } })).resolves.toMatchObject({
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
|
||||
});
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === lifecycleGeneralId)).toMatchObject({
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
|
||||
});
|
||||
|
||||
await reconciler.reconcileOnce();
|
||||
await expect(db.inputEvent.count({ where: { requestId: { startsWith: lifecycleRequestId } } })).resolves.toBe(
|
||||
1
|
||||
);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js';
|
||||
|
||||
const reset = (userId: string) => ({
|
||||
userId,
|
||||
resetRevision: '2026-07-31T09:00:00.001Z',
|
||||
current: {
|
||||
revision: '2026-07-31T09:00:00.002Z',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
});
|
||||
|
||||
describe('AccountIconResetReconciler', () => {
|
||||
it('replays stale resets, skips newer watermarks, and bootstraps matching post-reset tuples', async () => {
|
||||
const db = {
|
||||
general: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 1,
|
||||
userId: 'needs-reset',
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
meta: { accountIconUpdatedAt: '2026-07-31T08:59:59.999Z' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 'already-newer',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
meta: { accountIconUpdatedAt: '2026-07-31T09:00:00.002Z' },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userId: 'legacy-watermark',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
meta: {},
|
||||
},
|
||||
]),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
const source = {
|
||||
listResets: vi.fn(async () => [reset('needs-reset'), reset('already-newer'), reset('legacy-watermark')]),
|
||||
};
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000);
|
||||
|
||||
await reconciler.reconcileOnce();
|
||||
|
||||
expect(source.listResets).toHaveBeenCalledWith(['needs-reset', 'already-newer', 'legacy-watermark']);
|
||||
expect(transport.commands.map(({ command }) => command)).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'general:adjustIcon:needs-reset:2026-07-31T09:00:00.001Z',
|
||||
userId: 'needs-reset',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'general:adjustIcon:legacy-watermark:2026-07-31T09:00:00.002Z',
|
||||
userId: 'legacy-watermark',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.002Z',
|
||||
},
|
||||
]);
|
||||
expect(reconciler.getHealth()).toMatchObject({ lastSuccessAt: expect.any(String), lastError: null });
|
||||
});
|
||||
|
||||
it('requeues terminal events and isolates a persistent user failure from later users', async () => {
|
||||
const db = {
|
||||
general: {
|
||||
findMany: vi.fn(async () =>
|
||||
['terminal', 'broken', 'later'].map((userId, index) => ({
|
||||
id: index + 1,
|
||||
userId,
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
meta: {},
|
||||
}))
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async ({ where }: { where: { OR: Array<{ requestId: unknown }> } }) => {
|
||||
const first = where.OR[0]?.requestId;
|
||||
if (first === 'general:adjustIcon:broken:2026-07-31T09:00:00.001Z') {
|
||||
throw new Error('broken event lookup');
|
||||
}
|
||||
if (first === 'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z') {
|
||||
return [
|
||||
{
|
||||
requestId: first,
|
||||
status: 'FAILED',
|
||||
eventType: 'adjustGeneralIcon',
|
||||
payload: {
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: first,
|
||||
userId: 'terminal',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
const source = { listResets: vi.fn(async () => ['terminal', 'broken', 'later'].map(reset)) };
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000);
|
||||
|
||||
await expect(reconciler.reconcileOnce()).rejects.toThrow('1 account icon reset reconciliation');
|
||||
expect(transport.commands.map(({ command }) => command.requestId)).toEqual([
|
||||
'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z:retry:1',
|
||||
'general:adjustIcon:later:2026-07-31T09:00:00.001Z',
|
||||
]);
|
||||
expect(reconciler.getHealth()).toMatchObject({ lastErrorAt: expect.any(String) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GatewayHttpAccountIconSource } from '../src/auth/accountIconSource.js';
|
||||
|
||||
const projection = {
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('GatewayHttpAccountIconSource', () => {
|
||||
it('uses an encoded path and a purpose-derived credential', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: string | URL | Request, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify(projection), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret');
|
||||
await expect(source.get('user/한글')).resolves.toEqual(projection);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://gateway.internal/internal/account-icons/user%2F%ED%95%9C%EA%B8%80');
|
||||
expect(init?.headers).toMatchObject({
|
||||
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null only for a missing user', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 404 }))
|
||||
);
|
||||
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([401, 500])('rejects HTTP %s', async (status) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status }))
|
||||
);
|
||||
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow(
|
||||
`HTTP ${status}`
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed or over-broad responses', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ ...projection, email: 'must-not-leak@example.test' })))
|
||||
);
|
||||
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow(
|
||||
'invalid'
|
||||
);
|
||||
});
|
||||
|
||||
it('loads only strict reset projections for the requested users', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: string | URL | Request, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
resets: [
|
||||
{
|
||||
userId: 'user-1',
|
||||
resetRevision: '2026-07-31T09:00:00.001Z',
|
||||
current: projection,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret');
|
||||
await expect(source.listResets(['user-1'])).resolves.toEqual([
|
||||
{
|
||||
userId: 'user-1',
|
||||
resetRevision: '2026-07-31T09:00:00.001Z',
|
||||
current: projection,
|
||||
},
|
||||
]);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://gateway.internal/internal/account-icon-resets');
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userIds: ['user-1'] }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reset projections for users that were not requested', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
resets: [
|
||||
{
|
||||
userId: 'other-user',
|
||||
resetRevision: '2026-07-31T09:00:00.001Z',
|
||||
current: projection,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
new GatewayHttpAccountIconSource('http://gateway', 'secret').listResets(['user-1'])
|
||||
).rejects.toThrow('invalid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createBestEffortResourceCloser } from '../src/services/bestEffortResourceCloser.js';
|
||||
|
||||
describe('createBestEffortResourceCloser', () => {
|
||||
it('continues after a failed step and retries only the unfinished step', async () => {
|
||||
const calls: string[] = [];
|
||||
let rejectMiddle = true;
|
||||
const close = createBestEffortResourceCloser([
|
||||
{
|
||||
name: 'first',
|
||||
run: async () => {
|
||||
calls.push('first');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'middle',
|
||||
run: async () => {
|
||||
calls.push('middle');
|
||||
if (rejectMiddle) throw new Error('temporary failure');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'last',
|
||||
run: async () => {
|
||||
calls.push('last');
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(close()).rejects.toThrow('One or more resources failed to close.');
|
||||
expect(calls).toEqual(['first', 'middle', 'last']);
|
||||
|
||||
rejectMiddle = false;
|
||||
await close();
|
||||
await close();
|
||||
expect(calls).toEqual(['first', 'middle', 'last', 'middle']);
|
||||
});
|
||||
|
||||
it('shares one in-flight close across concurrent callers', async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const run = vi.fn(async () => pending);
|
||||
const close = createBestEffortResourceCloser([{ name: 'only', run }]);
|
||||
|
||||
const first = close();
|
||||
const second = close();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
release?.();
|
||||
await Promise.all([first, second]);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -25,4 +25,22 @@ describe('resolveGameApiConfigFromEnv', () => {
|
||||
|
||||
expect(config.profileName).toBe('hwe:2');
|
||||
});
|
||||
|
||||
it.each(['0', '-1', '1.5', '999', 'Infinity'])('rejects unsafe account icon reconcile interval %s', (value) => {
|
||||
expect(() =>
|
||||
resolveGameApiConfigFromEnv({
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: value,
|
||||
})
|
||||
).toThrow('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
});
|
||||
|
||||
it('accepts a bounded integer account icon reconcile interval', () => {
|
||||
expect(
|
||||
resolveGameApiConfigFromEnv({
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: '1000',
|
||||
}).accountIconResetReconcileIntervalMs
|
||||
).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
||||
legacyMemberNo,
|
||||
picture: 'custom-owner.webp',
|
||||
imageServer: 2,
|
||||
iconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
||||
canUseGeneralPicture: true,
|
||||
},
|
||||
sanctions: {
|
||||
@@ -92,6 +93,16 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'create-general-test-secret',
|
||||
accountIconSource: {
|
||||
get: async (accountId) =>
|
||||
accountId === auth.user.id && auth.user.iconUpdatedAt
|
||||
? {
|
||||
revision: auth.user.iconUpdatedAt,
|
||||
picture: auth.user.picture ?? 'default.jpg',
|
||||
imageServer: auth.user.imageServer ?? 0,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -273,6 +284,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
ownerName: '생성사용자',
|
||||
killturn: 6,
|
||||
inherit_spent_dyn: 4500,
|
||||
accountIconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
||||
});
|
||||
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
|
||||
if (!createdAccess.lastRefresh) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
|
||||
describe('RedisGatewayFlushSubscriber', () => {
|
||||
it('reports asynchronous flush handler failures with event context', async () => {
|
||||
let listener: ((message: string) => void) | undefined;
|
||||
const client = {
|
||||
subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => {
|
||||
listener = next;
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => undefined),
|
||||
};
|
||||
const error = new Error('durable enqueue unavailable');
|
||||
const onError = vi.fn();
|
||||
const subscriber = new RedisGatewayFlushSubscriber(
|
||||
client,
|
||||
'flush',
|
||||
new InMemoryFlushStore(),
|
||||
async () => Promise.reject(error),
|
||||
onError
|
||||
);
|
||||
await subscriber.start();
|
||||
const event = {
|
||||
userId: 'user-1',
|
||||
flushedAt: '2026-07-31T09:00:00.001Z',
|
||||
reason: 'admin-profile-icon-reset',
|
||||
iconRevision: '2026-07-31T09:00:00.001Z',
|
||||
};
|
||||
|
||||
listener?.(JSON.stringify(event));
|
||||
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error, event));
|
||||
await subscriber.stop();
|
||||
});
|
||||
|
||||
it('unsubscribes and drains an in-flight durable flush before stopping', async () => {
|
||||
let listener: ((message: string) => void) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const client = {
|
||||
subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => {
|
||||
listener = next;
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => undefined),
|
||||
};
|
||||
const onFlush = vi.fn(
|
||||
async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
const subscriber = new RedisGatewayFlushSubscriber(client, 'flush', new InMemoryFlushStore(), onFlush);
|
||||
await subscriber.start();
|
||||
|
||||
listener?.(JSON.stringify({ userId: 'user-1', flushedAt: '2026-07-31T09:00:00.001Z' }));
|
||||
await vi.waitFor(() => expect(onFlush).toHaveBeenCalledOnce());
|
||||
let stopped = false;
|
||||
const stopping = subscriber.stop().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(client.unsubscribe).toHaveBeenCalledOnce());
|
||||
expect(stopped).toBe(false);
|
||||
|
||||
release?.();
|
||||
await stopping;
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
DatabaseClient,
|
||||
@@ -15,7 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const profile: GameProfile = {
|
||||
id: 'che',
|
||||
@@ -114,6 +114,9 @@ const buildContext = (options?: {
|
||||
generalTurnWrites?: unknown[];
|
||||
nationTurnWrites?: unknown[];
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
currentAccountIcon?: unknown;
|
||||
accountIconGet?: (userId: string) => Promise<unknown>;
|
||||
accessTokenStore?: RedisAccessTokenStore;
|
||||
worldStateReads?: { count: number };
|
||||
}): GameApiContext => {
|
||||
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
||||
@@ -227,10 +230,30 @@ const buildContext = (options?: {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis: {} as unknown as RedisConnector['client'],
|
||||
accessTokenStore,
|
||||
redis: {
|
||||
get: async () => null,
|
||||
} as unknown as RedisConnector['client'],
|
||||
accessTokenStore: options?.accessTokenStore ?? accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
accountIconSource: {
|
||||
get: async (userId: string) => {
|
||||
if (options?.accountIconGet) {
|
||||
return (await options.accountIconGet(userId)) as {
|
||||
revision: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
} | null;
|
||||
}
|
||||
return options?.currentAccountIcon === undefined
|
||||
? null
|
||||
: (options.currentAccountIcon as {
|
||||
revision: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -280,6 +303,140 @@ describe('appRouter', () => {
|
||||
await expect(caller.auth.status()).resolves.toEqual({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('keeps ordinary account icon changes on the explicitly selected servers', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const accountIconGet = vi.fn(async () => {
|
||||
throw new Error('ordinary exchange must not read the icon source');
|
||||
});
|
||||
const accessTokenStore = {
|
||||
markGatewayTokenUsed: vi.fn(async () => true),
|
||||
create: vi.fn(async () => ({
|
||||
accessToken: 'ga_access',
|
||||
expiresAt: '2099-01-01T01:00:00.000Z',
|
||||
})),
|
||||
} as unknown as RedisAccessTokenStore;
|
||||
const payload = buildAuth();
|
||||
payload.issuedAt = '2099-01-01T00:00:00.000Z';
|
||||
payload.expiresAt = '2099-01-01T01:00:00.000Z';
|
||||
payload.user.iconUpdatedAt = '2099-01-01T00:00:00.000Z';
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
auth: null,
|
||||
transport,
|
||||
accountIconGet,
|
||||
accessTokenStore,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.auth.exchangeGatewayToken({
|
||||
gatewayToken: encryptGameSessionToken(payload, 'test-secret'),
|
||||
})
|
||||
).resolves.toMatchObject({ accessToken: 'ga_access' });
|
||||
expect(accountIconGet).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('durably re-applies an administrator reset before consuming the one-time token', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const calls: string[] = [];
|
||||
const revision = '2099-01-01T00:00:00.001Z';
|
||||
const accessTokenStore = {
|
||||
markGatewayTokenUsed: vi.fn(async () => {
|
||||
calls.push('mark-used');
|
||||
return true;
|
||||
}),
|
||||
create: vi.fn(async () => ({
|
||||
accessToken: 'ga_access',
|
||||
expiresAt: '2099-01-01T01:00:00.000Z',
|
||||
})),
|
||||
} as unknown as RedisAccessTokenStore;
|
||||
const payload = buildAuth();
|
||||
payload.issuedAt = '2099-01-01T00:00:00.000Z';
|
||||
payload.expiresAt = '2099-01-01T01:00:00.000Z';
|
||||
payload.user.iconUpdatedAt = revision;
|
||||
payload.user.profileIconResetAt = revision;
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
auth: null,
|
||||
transport,
|
||||
accessTokenStore,
|
||||
accountIconGet: async () => {
|
||||
calls.push('projection');
|
||||
return {
|
||||
revision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await caller.auth.exchangeGatewayToken({
|
||||
gatewayToken: encryptGameSessionToken(payload, 'test-secret'),
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['projection', 'mark-used']);
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: `general:adjustIcon:${payload.user.id}:${revision}`,
|
||||
userId: payload.user.id,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: revision,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the current Gateway database icon instead of stale token claims', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const currentAccountIcon = {
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
};
|
||||
const auth = buildAuth();
|
||||
auth.user.picture = 'stale.png';
|
||||
auth.user.imageServer = 0;
|
||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
|
||||
transport.setCommandResult(requestId, {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
updated: true,
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
auth,
|
||||
transport,
|
||||
currentAccountIcon,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(caller.general.adjustIcon()).resolves.toEqual({
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
updated: true,
|
||||
});
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId: auth.user.id,
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
iconRevision: currentAccountIcon.revision,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects icon adjustment without auth or a current Gateway account', async () => {
|
||||
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
|
||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
||||
});
|
||||
|
||||
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
||||
await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
@@ -356,6 +513,85 @@ describe('appRouter', () => {
|
||||
expect(worldStateReads.count).toBe(0);
|
||||
});
|
||||
|
||||
it('does not require the Gateway icon source when creating a default-picture general', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const clientRequestId = '1b9afacd-d29b-456d-8ef3-a6be4b497e6e';
|
||||
const requestId = `join-create:user-1:${clientRequestId}`;
|
||||
transport.setCommandResult(requestId, {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: true,
|
||||
generalId: 41,
|
||||
});
|
||||
const accountIconGet = vi.fn(async () => {
|
||||
throw new Error('default-picture join must not read the icon source');
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
state: buildWorldState(),
|
||||
transport,
|
||||
accountIconGet,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.join.createGeneral({
|
||||
name: '기본전콘',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'Random',
|
||||
clientRequestId,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, generalId: 41 });
|
||||
expect(accountIconGet).not.toHaveBeenCalled();
|
||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
||||
});
|
||||
|
||||
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
||||
const requestId = `join-create:user-1:${clientRequestId}`;
|
||||
const revision = '2026-07-31T09:00:00.001Z';
|
||||
transport.setCommandResult(requestId, {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: true,
|
||||
generalId: 42,
|
||||
});
|
||||
const auth = buildAuth();
|
||||
auth.user.picture = 'stale.png';
|
||||
auth.user.imageServer = 0;
|
||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
state: buildWorldState(),
|
||||
auth,
|
||||
transport,
|
||||
currentAccountIcon: {
|
||||
revision,
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await caller.join.createGeneral({
|
||||
name: '최신전콘',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: true,
|
||||
character: 'Random',
|
||||
clientRequestId,
|
||||
});
|
||||
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
ownerPicture: 'latest.png',
|
||||
ownerImageServer: 1,
|
||||
ownerIconRevision: revision,
|
||||
});
|
||||
});
|
||||
|
||||
it('queues turn daemon run commands', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common';
|
||||
import { isCanonicalIsoTimestamp } from '@sammo-ts/common';
|
||||
|
||||
export type TurnDaemonCommandEnvelope = {
|
||||
requestId: string;
|
||||
@@ -252,6 +253,17 @@ const zPatchGeneral = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const zAdjustGeneralIcon = z
|
||||
.object({
|
||||
type: z.literal('adjustGeneralIcon'),
|
||||
requestId: z.string().min(1).optional(),
|
||||
userId: z.string().min(1),
|
||||
picture: z.string().min(1),
|
||||
imageServer: z.number().int().nonnegative(),
|
||||
iconRevision: z.string().refine(isCanonicalIsoTimestamp),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zJoinCreateGeneral = z
|
||||
.object({
|
||||
type: z.literal('joinCreateGeneral'),
|
||||
@@ -268,6 +280,7 @@ const zJoinCreateGeneral = z
|
||||
profileId: z.string().min(1),
|
||||
ownerPicture: z.string().optional(),
|
||||
ownerImageServer: zFiniteNumber.optional(),
|
||||
ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
ownerCanUsePicture: z.boolean().optional(),
|
||||
ownerLegacyPenalty: zRecord.optional(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
@@ -566,6 +579,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
|
||||
const command = parseWith(zAdjustGeneralIcon, envelope.command);
|
||||
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => {
|
||||
const command = parseWith(zJoinCreateGeneral, envelope.command);
|
||||
if (!command) {
|
||||
@@ -662,6 +683,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
patchGeneral: normalizePatchGeneral,
|
||||
adjustGeneralIcon: normalizeAdjustGeneralIcon,
|
||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||
npcPossessGeneral: normalizeNpcPossessGeneral,
|
||||
selectPoolCreate: normalizeSelectPoolCreate,
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface JoinCreateGeneralInput {
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
@@ -700,6 +701,12 @@ export const createGeneralFromJoin = async (options: {
|
||||
input.ownerPicture !== 'default.jpg';
|
||||
const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg';
|
||||
const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0;
|
||||
const accountIconUpdatedAt =
|
||||
input.ownerIconRevision &&
|
||||
picture === input.ownerPicture &&
|
||||
imageServer === Math.max(0, Math.floor(input.ownerImageServer ?? 0))
|
||||
? input.ownerIconRevision
|
||||
: undefined;
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
@@ -781,6 +788,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
newvote: 0,
|
||||
inherit_spent_dyn: inheritRequiredPoint,
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
...(accountIconUpdatedAt ? { accountIconUpdatedAt } : {}),
|
||||
},
|
||||
};
|
||||
if (!world.addGeneral(general)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
TurnDaemonCommandResult,
|
||||
} from '../lifecycle/types.js';
|
||||
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
|
||||
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asRecord, isCanonicalIsoTimestamp, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
@@ -133,7 +133,7 @@ interface CommandHandlerContext {
|
||||
|
||||
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
if (!ctx.commandDb) {
|
||||
throw new Error('ENGINE mutation transaction is required for selection-pool commands.');
|
||||
throw new Error('ENGINE mutation transaction is required for authenticated commands.');
|
||||
}
|
||||
return ctx.commandDb as unknown as DatabaseClient;
|
||||
};
|
||||
@@ -149,7 +149,8 @@ const resolveCommandAcceptedAt = async (
|
||||
| 'joinCreateGeneral'
|
||||
| 'npcPossessGeneral'
|
||||
| 'selectPoolCreate'
|
||||
| 'selectPoolReselect';
|
||||
| 'selectPoolReselect'
|
||||
| 'adjustGeneralIcon';
|
||||
}
|
||||
>
|
||||
): Promise<Date> => {
|
||||
@@ -228,6 +229,9 @@ async function handleJoinCreateGeneral(
|
||||
profileId: command.profileId,
|
||||
...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}),
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerIconRevision !== undefined
|
||||
? { ownerIconRevision: command.ownerIconRevision }
|
||||
: {}),
|
||||
...(command.ownerCanUsePicture !== undefined
|
||||
? { ownerCanUsePicture: command.ownerCanUsePicture }
|
||||
: {}),
|
||||
@@ -702,6 +706,78 @@ async function handlePatchGeneral(
|
||||
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleAdjustGeneralIcon(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
await resolveCommandAcceptedAt(db, command);
|
||||
const general = ctx.world
|
||||
.listGenerals()
|
||||
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
|
||||
if (!general) {
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: null,
|
||||
updated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentRevision = general.meta.accountIconUpdatedAt;
|
||||
if (currentRevision !== undefined) {
|
||||
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: false,
|
||||
code: 'PRECONDITION_FAILED',
|
||||
reason: '장수의 계정 아이콘 revision이 올바르지 않습니다.',
|
||||
};
|
||||
}
|
||||
const currentRevisionTime = new Date(currentRevision).getTime();
|
||||
const nextRevisionTime = new Date(command.iconRevision).getTime();
|
||||
if (nextRevisionTime < currentRevisionTime) {
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: false,
|
||||
code: 'CONFLICT',
|
||||
reason: '더 최신 계정 아이콘이 이미 적용되었습니다.',
|
||||
};
|
||||
}
|
||||
if (nextRevisionTime === currentRevisionTime) {
|
||||
if (general.picture === command.picture && general.imageServer === command.imageServer) {
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: general.id,
|
||||
updated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: false,
|
||||
code: 'CONFLICT',
|
||||
reason: '같은 revision에 다른 계정 아이콘이 요청되었습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ctx.world.updateGeneral(general.id, {
|
||||
picture: command.picture,
|
||||
imageServer: command.imageServer,
|
||||
meta: {
|
||||
...general.meta,
|
||||
accountIconUpdatedAt: command.iconRevision,
|
||||
},
|
||||
});
|
||||
return {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: general.id,
|
||||
updated: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleShiftSchedule(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>
|
||||
@@ -2296,6 +2372,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||
patchGeneral: (command) =>
|
||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
adjustGeneralIcon: (command) =>
|
||||
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
|
||||
joinCreateGeneral: (command) =>
|
||||
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||
npcPossessGeneral: (command) =>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const revision = '2026-07-31T09:00:00.000Z';
|
||||
const requestId = `general:adjustIcon:user-1:${revision}`;
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '아이콘장수',
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
turnTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
officerLevel: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
picture: 'old.jpg',
|
||||
imageServer: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildWorld = (generals: TurnGeneral[]) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||
meta: {},
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals,
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
const buildCommandDb = (actorUserId = 'user-1') =>
|
||||
({
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date('2026-07-31T09:00:00.000Z'),
|
||||
actorUserId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralIcon',
|
||||
})),
|
||||
},
|
||||
}) as unknown as GamePrisma.TransactionClient;
|
||||
|
||||
const command = (
|
||||
overrides: Partial<{
|
||||
requestId: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconRevision: string;
|
||||
}> = {}
|
||||
) => ({
|
||||
type: 'adjustGeneralIcon' as const,
|
||||
requestId,
|
||||
userId: 'user-1',
|
||||
picture: 'new.png',
|
||||
imageServer: 1,
|
||||
iconRevision: revision,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('adjustGeneralIcon ENGINE command', () => {
|
||||
it('updates only the authenticated human general and preserves existing meta', async () => {
|
||||
const human = buildGeneral();
|
||||
const possessedNpc = buildGeneral({ id: 2, npcState: 1 });
|
||||
const foreign = buildGeneral({ id: 3, userId: 'user-2' });
|
||||
const world = buildWorld([human, possessedNpc, foreign]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: human.id,
|
||||
updated: true,
|
||||
});
|
||||
expect(world.getGeneralById(human.id)).toMatchObject({
|
||||
picture: 'new.png',
|
||||
imageServer: 1,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
accountIconUpdatedAt: revision,
|
||||
},
|
||||
});
|
||||
expect(world.getGeneralById(possessedNpc.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
||||
expect(world.getGeneralById(foreign.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
||||
});
|
||||
|
||||
it('treats no human general as a successful no-op', async () => {
|
||||
const world = buildWorld([buildGeneral({ id: 2, npcState: 1 }), buildGeneral({ id: 3, userId: 'user-2' })]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: null,
|
||||
updated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an identical revision idempotent and rejects rollback or tuple conflicts', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral({
|
||||
picture: 'new.png',
|
||||
imageServer: 1,
|
||||
meta: { killturn: 24, accountIconUpdatedAt: revision },
|
||||
}),
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
updated: false,
|
||||
});
|
||||
await expect(
|
||||
handler.handle(command({ iconRevision: '2026-07-31T08:59:59.000Z' }), {
|
||||
db: buildCommandDb(),
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
|
||||
await expect(
|
||||
handler.handle(command({ picture: 'conflict.png' }), { db: buildCommandDb() })
|
||||
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
|
||||
expect(world.getGeneralById(1)).toMatchObject({ picture: 'new.png', imageServer: 1 });
|
||||
});
|
||||
|
||||
it('fails closed for a corrupt watermark or mismatched input-event actor', async () => {
|
||||
const corruptWorld = buildWorld([buildGeneral({ meta: { killturn: 24, accountIconUpdatedAt: 'not-a-date' } })]);
|
||||
const corruptHandler = createTurnDaemonCommandHandler({ world: corruptWorld });
|
||||
await expect(corruptHandler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'PRECONDITION_FAILED',
|
||||
});
|
||||
|
||||
const actorWorld = buildWorld([buildGeneral()]);
|
||||
const actorHandler = createTurnDaemonCommandHandler({ world: actorWorld });
|
||||
await expect(actorHandler.handle(command(), { db: buildCommandDb('user-2') })).rejects.toThrow(
|
||||
'actor does not match'
|
||||
);
|
||||
expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
|
||||
const normalize = (command: Record<string, unknown>) =>
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'icon-request',
|
||||
sentAt: '2026-07-31T09:00:00.000Z',
|
||||
command: command as unknown as TurnDaemonCommand,
|
||||
});
|
||||
|
||||
describe('adjustGeneralIcon command registry', () => {
|
||||
it('accepts only a canonical authenticated icon projection', () => {
|
||||
expect(
|
||||
normalize({
|
||||
type: 'adjustGeneralIcon',
|
||||
userId: 'user-1',
|
||||
picture: 'owner.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'icon-request',
|
||||
userId: 'user-1',
|
||||
picture: 'owner.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.000Z',
|
||||
});
|
||||
expect(
|
||||
normalize({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'icon-request',
|
||||
userId: 'user-1',
|
||||
picture: 'owner.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: 'icon-request',
|
||||
userId: 'user-1',
|
||||
picture: 'owner.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty owner', { userId: '' }],
|
||||
['empty picture', { picture: '' }],
|
||||
['negative image server', { imageServer: -1 }],
|
||||
['fractional image server', { imageServer: 1.5 }],
|
||||
['noncanonical revision', { iconRevision: '2026-07-31T09:00:00Z' }],
|
||||
['mismatched durable request ID', { requestId: 'different-request' }],
|
||||
['unknown field', { generalId: 1 }],
|
||||
])('rejects %s', (_label, override) => {
|
||||
expect(
|
||||
normalize({
|
||||
type: 'adjustGeneralIcon',
|
||||
userId: 'user-1',
|
||||
picture: 'owner.png',
|
||||
imageServer: 1,
|
||||
iconRevision: '2026-07-31T09:00:00.000Z',
|
||||
...override,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
|
||||
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const worldId = 991_741;
|
||||
const targetGeneralId = 991_741;
|
||||
const npcGeneralId = 991_742;
|
||||
const foreignGeneralId = 991_743;
|
||||
const rollbackRequestId = 'integration:engine:account-icon:rollback';
|
||||
const actorMismatchRequestId = 'integration:engine:account-icon:actor-mismatch';
|
||||
const successRequestId = 'integration:engine:account-icon:success';
|
||||
const noGeneralRequestId = 'integration:engine:account-icon:no-general';
|
||||
const rollbackConstraint = 'account_icon_rollback_test';
|
||||
const targetUserId = 'account-icon-target-user';
|
||||
const npcUserId = 'account-icon-npc-user';
|
||||
const foreignUserId = 'account-icon-foreign-user';
|
||||
const initialPicture = 'account-icon-old.jpg';
|
||||
const initialImageServer = 0;
|
||||
const nextPicture = 'account-icon-new.png';
|
||||
const nextImageServer = 1;
|
||||
const rollbackRevision = '2026-07-31T09:05:00.000Z';
|
||||
const successRevision = '2026-07-31T09:10:00.000Z';
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
if (!schema?.endsWith('immediate_action_integration')) {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const scenarioConfig: ScenarioConfig = {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: {
|
||||
mapName: 'che',
|
||||
unitSet: 'che',
|
||||
},
|
||||
};
|
||||
|
||||
const scenarioMeta: ScenarioMeta = {
|
||||
title: '계정 아이콘 영속성 통합',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
};
|
||||
|
||||
const map: MapDefinition = {
|
||||
id: 'account-icon-integration',
|
||||
name: '계정 아이콘 영속성 통합',
|
||||
cities: [],
|
||||
};
|
||||
|
||||
const state: TurnWorldState = {
|
||||
id: worldId,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||
meta: {
|
||||
killturn: 24,
|
||||
lastTurnTime: '2026-07-31T09:00:00.000Z',
|
||||
scenarioMeta,
|
||||
},
|
||||
};
|
||||
|
||||
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||
id: targetGeneralId,
|
||||
userId: targetUserId,
|
||||
name: '아이콘통합장수',
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
turnTime: new Date('2026-07-31T09:10:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24, preserved: 'yes' },
|
||||
penalty: {},
|
||||
officerLevel: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const targetGeneral = buildGeneral({});
|
||||
const npcGeneral = buildGeneral({
|
||||
id: npcGeneralId,
|
||||
userId: npcUserId,
|
||||
name: '빙의NPC',
|
||||
npcState: 1,
|
||||
});
|
||||
const foreignGeneral = buildGeneral({
|
||||
id: foreignGeneralId,
|
||||
userId: foreignUserId,
|
||||
name: '타사용자장수',
|
||||
});
|
||||
const generals = [targetGeneral, npcGeneral, foreignGeneral];
|
||||
|
||||
const buildCommand = (
|
||||
requestId: string,
|
||||
userId: string,
|
||||
iconRevision: string,
|
||||
picture = nextPicture,
|
||||
imageServer = nextImageServer
|
||||
): Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }> => ({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture,
|
||||
imageServer,
|
||||
iconRevision,
|
||||
});
|
||||
|
||||
const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
|
||||
id: general.id,
|
||||
userId: general.userId,
|
||||
name: general.name,
|
||||
nationId: general.nationId,
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
npcState: general.npcState,
|
||||
leadership: general.stats.leadership,
|
||||
strength: general.stats.strength,
|
||||
intel: general.stats.intelligence,
|
||||
officerLevel: general.officerLevel,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
injury: general.injury,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
crewTypeId: general.crewTypeId,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime,
|
||||
age: general.age,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
meta: general.meta as GamePrisma.InputJsonValue,
|
||||
penalty: general.penalty as GamePrisma.InputJsonValue,
|
||||
});
|
||||
|
||||
integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let hooks: DatabaseTurnHooks | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedDatabase(databaseUrl!);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
|
||||
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`);
|
||||
await db.inputEvent.deleteMany({
|
||||
where: {
|
||||
requestId: {
|
||||
in: [rollbackRequestId, actorMismatchRequestId, successRequestId, noGeneralRequestId],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.rankData.deleteMany({
|
||||
where: { generalId: { in: generals.map((general) => general.id) } },
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: { id: { in: generals.map((general) => general.id) } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
id: worldId,
|
||||
scenarioCode: 'account-icon-integration',
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: generals.map(toGeneralCreate),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await hooks?.close();
|
||||
if (!db) {
|
||||
await disconnect?.();
|
||||
return;
|
||||
}
|
||||
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`);
|
||||
await db.inputEvent.deleteMany({
|
||||
where: {
|
||||
requestId: {
|
||||
in: [rollbackRequestId, actorMismatchRequestId, successRequestId, noGeneralRequestId],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.rankData.deleteMany({
|
||||
where: { generalId: { in: generals.map((general) => general.id) } },
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: { id: { in: generals.map((general) => general.id) } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
it('commits the authenticated human icon with input_event and rolls both back on failure', async () => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals,
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig,
|
||||
scenarioMeta,
|
||||
map,
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('world', {
|
||||
capture: () => world.captureState(),
|
||||
restore: (captured) => world.restoreState(captured),
|
||||
});
|
||||
const execute = async (
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
if (!hooks?.hooks.executeCommand || !command.requestId) {
|
||||
throw new Error('Database command execution hook is unavailable.');
|
||||
}
|
||||
return stateManager.transaction(() =>
|
||||
hooks!.hooks.executeCommand!(command.requestId!, async (context) => {
|
||||
const result = await handler.handle(command, context);
|
||||
if (!result) {
|
||||
throw new Error('adjustGeneralIcon command was not handled.');
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
};
|
||||
const createInputEvent = async (
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>,
|
||||
actorUserId = command.userId
|
||||
): Promise<void> => {
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId: command.requestId!,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId,
|
||||
status: 'PROCESSING',
|
||||
lockedBy: 'account-icon-integration-worker',
|
||||
leaseUntil: new Date('2026-07-31T09:30:00.000Z'),
|
||||
attempts: 1,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const rollbackCommand = buildCommand(rollbackRequestId, targetUserId, rollbackRevision, 'must-rollback.png', 7);
|
||||
await createInputEvent(rollbackCommand);
|
||||
await db.$executeRawUnsafe(`
|
||||
ALTER TABLE input_event
|
||||
ADD CONSTRAINT ${rollbackConstraint}
|
||||
CHECK (request_id <> '${rollbackRequestId}' OR status <> 'SUCCEEDED')
|
||||
`);
|
||||
await expect(execute(rollbackCommand)).rejects.toThrow(`violates check constraint "${rollbackConstraint}"`);
|
||||
expect(world.getGeneralById(targetGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
meta: { killturn: 24, preserved: 'yes' },
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
meta: { killturn: 24, preserved: 'yes' },
|
||||
});
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: rollbackRequestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
result: null,
|
||||
lockedBy: 'account-icon-integration-worker',
|
||||
});
|
||||
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${rollbackConstraint}`);
|
||||
|
||||
const actorMismatchCommand = buildCommand(
|
||||
actorMismatchRequestId,
|
||||
targetUserId,
|
||||
rollbackRevision,
|
||||
'actor-mismatch.png',
|
||||
8
|
||||
);
|
||||
await createInputEvent(actorMismatchCommand, foreignUserId);
|
||||
await expect(execute(actorMismatchCommand)).rejects.toThrow('actor does not match');
|
||||
expect(world.getGeneralById(targetGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: actorMismatchRequestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
result: null,
|
||||
});
|
||||
|
||||
const successCommand = buildCommand(successRequestId, targetUserId, successRevision);
|
||||
await createInputEvent(successCommand);
|
||||
await expect(execute(successCommand)).resolves.toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: targetGeneralId,
|
||||
updated: true,
|
||||
});
|
||||
expect(world.getGeneralById(targetGeneralId)).toMatchObject({
|
||||
picture: nextPicture,
|
||||
imageServer: nextImageServer,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
preserved: 'yes',
|
||||
accountIconUpdatedAt: successRevision,
|
||||
},
|
||||
});
|
||||
expect(world.getGeneralById(npcGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
expect(world.getGeneralById(foreignGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({
|
||||
picture: nextPicture,
|
||||
imageServer: nextImageServer,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
preserved: 'yes',
|
||||
accountIconUpdatedAt: successRevision,
|
||||
},
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: npcGeneralId } })).resolves.toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: foreignGeneralId } })).resolves.toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: successRequestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: targetGeneralId,
|
||||
updated: true,
|
||||
},
|
||||
error: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
completedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
const noGeneralCommand = buildCommand(noGeneralRequestId, npcUserId, successRevision, 'npc-must-stay.png', 9);
|
||||
await createInputEvent(noGeneralCommand);
|
||||
await expect(execute(noGeneralCommand)).resolves.toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: null,
|
||||
updated: false,
|
||||
});
|
||||
expect(world.getGeneralById(npcGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
});
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: npcGeneralId } })).resolves.toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
meta: { killturn: 24, preserved: 'yes' },
|
||||
});
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: noGeneralRequestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: null,
|
||||
updated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === targetGeneralId)).toMatchObject({
|
||||
picture: nextPicture,
|
||||
imageServer: nextImageServer,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
preserved: 'yes',
|
||||
accountIconUpdatedAt: successRevision,
|
||||
},
|
||||
});
|
||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === npcGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
npcState: 1,
|
||||
});
|
||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === foreignGeneralId)).toMatchObject({
|
||||
picture: initialPicture,
|
||||
imageServer: initialImageServer,
|
||||
userId: foreignUserId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,14 @@ const integration = describe.skipIf(!databaseUrl);
|
||||
const profileName = 'runtime:consumer-integration';
|
||||
const actionId = '924f40ec-e9d2-432f-9867-e9fb3199f14a';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
const waitForApplied = async (db: GatewayPrismaClient): Promise<void> => {
|
||||
const deadline = Date.now() + 4_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -27,11 +35,14 @@ const waitForApplied = async (db: GatewayPrismaClient): Promise<void> => {
|
||||
integration('gateway runtime action consumer', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
initialized = true;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.gatewayProfile.upsert({
|
||||
where: { profileName },
|
||||
@@ -48,8 +59,10 @@ integration('gateway runtime action consumer', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.gatewayRuntimeAction.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
if (initialized) {
|
||||
await db.gatewayRuntimeAction.deleteMany({ where: { profileName } });
|
||||
await db.gatewayProfile.deleteMany({ where: { profileName } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { resolve } from 'node:path';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const gameBasePath = (process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/gu, '');
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${gameBasePath}:default`;
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
@@ -72,8 +74,8 @@ const generals = [
|
||||
id: 10,
|
||||
name: '조조',
|
||||
ownerName: null,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
picture: '계정 icon.png',
|
||||
imageServer: 1,
|
||||
npcState: 0,
|
||||
age: 35,
|
||||
nationId: 1,
|
||||
@@ -101,7 +103,7 @@ const generals = [
|
||||
id: 20,
|
||||
name: '유비',
|
||||
ownerName: '통일유저',
|
||||
picture: 'default.jpg',
|
||||
picture: '장수/유비 1.png',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
age: 34,
|
||||
@@ -145,10 +147,10 @@ const install = async (
|
||||
accessPages: string[] = []
|
||||
) => {
|
||||
let generalDirectoryCalls = 0;
|
||||
await page.addInitScript(() => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_directory');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
}, gameProfile);
|
||||
await page.route('**/image/general/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -156,10 +158,24 @@ const install = async (
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
})
|
||||
);
|
||||
await page.route('**/gateway/api/user-icons/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
})
|
||||
);
|
||||
await page.route('**/image/icons/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
})
|
||||
);
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
@@ -202,12 +218,12 @@ const install = async (
|
||||
};
|
||||
|
||||
const installAccessBoundary = async (page: Page, accessPages: string[]) => {
|
||||
await page.addInitScript(() => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_access_boundary');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
}, gameProfile);
|
||||
await page.route('**/image/**', (route) => route.abort('failed'));
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
@@ -315,6 +331,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||
const icon = page.locator('.general-icon').first();
|
||||
await expect(icon).toBeVisible();
|
||||
await expect(icon).toHaveAttribute('src', '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon.png');
|
||||
expect(
|
||||
await icon.evaluate((element) => {
|
||||
const image = element as HTMLImageElement;
|
||||
@@ -328,6 +345,11 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
};
|
||||
})
|
||||
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
|
||||
const nestedLegacyIcon = page.locator('.general-icon').nth(1);
|
||||
await expect(nestedLegacyIcon).toHaveAttribute('src', '/image/general/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png');
|
||||
await expect
|
||||
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
|
||||
.toBe(64);
|
||||
|
||||
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
|
||||
if (artifactRoot) {
|
||||
@@ -354,6 +376,23 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus
|
||||
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
});
|
||||
|
||||
test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.route('**/gateway/api/user-icons/**', (route) =>
|
||||
route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' })
|
||||
);
|
||||
await page.goto('general-list');
|
||||
|
||||
const icon = page.locator('.general-icon').first();
|
||||
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/);
|
||||
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
||||
await icon.evaluate((element) => {
|
||||
(element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png';
|
||||
});
|
||||
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/);
|
||||
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
||||
});
|
||||
|
||||
test('a failed resort retains the selected value and existing rows', async ({ page }) => {
|
||||
await install(page, 'error-after-load');
|
||||
await page.goto('general-list');
|
||||
@@ -368,7 +407,7 @@ test('an authenticated account without a general is redirected away from both di
|
||||
await install(page, 'no-general');
|
||||
for (const path of ['nation-list', 'general-list']) {
|
||||
await page.goto(path);
|
||||
await expect(page).toHaveURL(/\/che\/join$/);
|
||||
await expect(page).toHaveURL(new RegExp(`/${gameBasePath}/join$`, 'u'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -388,7 +427,9 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page
|
||||
for (const [path, pageName] of retained) {
|
||||
const before = accessPages.length;
|
||||
await page.goto(path);
|
||||
await expect(page.locator('#app')).toHaveAttribute('data-v-app', '');
|
||||
await expect.poll(() => accessPages.length).toBe(before + 1);
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(accessPages.at(-1)).toBe(pageName);
|
||||
}
|
||||
|
||||
@@ -415,7 +456,7 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page
|
||||
for (const path of endpointOwned) {
|
||||
const before = accessPages.length;
|
||||
await page.goto(path);
|
||||
await page.waitForTimeout(50);
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(accessPages).toHaveLength(before);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -220,7 +220,7 @@ test.describe('scenario 903 live selection pool', () => {
|
||||
).toBe(true);
|
||||
expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true);
|
||||
const fallbackImages = page.locator(
|
||||
'.card-holder .portrait img[data-fallback-applied="true"]'
|
||||
'.card-holder .portrait img[data-general-icon-fallback-source]'
|
||||
);
|
||||
await expect(fallbackImages).not.toHaveCount(0);
|
||||
expect(assetTracker.userIconRequests).toBe(await fallbackImages.count());
|
||||
@@ -389,7 +389,7 @@ test.describe('scenario 903 live selection pool', () => {
|
||||
|
||||
const candidateCards = page.locator('.card-holder > .general-card');
|
||||
const fallbackCard = candidateCards
|
||||
.filter({ has: page.locator('img[data-fallback-applied="true"]') })
|
||||
.filter({ has: page.locator('img[data-general-icon-fallback-source]') })
|
||||
.first();
|
||||
await expect(fallbackCard).toBeVisible();
|
||||
const initialName = await fallbackCard.locator('h4').first().textContent();
|
||||
@@ -397,7 +397,7 @@ test.describe('scenario 903 live selection pool', () => {
|
||||
await fallbackCard.locator('.select-button').click();
|
||||
await expect(page.locator('.selected-card')).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('.selected-card img[data-fallback-applied="true"]')
|
||||
page.locator('.selected-card img[data-general-icon-fallback-source]')
|
||||
).toBeVisible();
|
||||
expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview);
|
||||
await page.locator('.custom-form select').selectOption('che_안전');
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm test:unit",
|
||||
"test:unit": "node --test test/**/*.test.ts",
|
||||
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
|
||||
@@ -20,7 +22,6 @@
|
||||
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import type { MessageType } from '@sammo-ts/logic';
|
||||
import { resolveMessageGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
|
||||
|
||||
interface MessageTarget {
|
||||
generalId: number;
|
||||
@@ -105,16 +106,7 @@ const isBright = (color: string): boolean => {
|
||||
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
|
||||
};
|
||||
|
||||
const iconUrl = computed(() => {
|
||||
const icon = props.message.src.icon?.trim();
|
||||
if (!icon) {
|
||||
return '/image/icons/default.jpg';
|
||||
}
|
||||
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
|
||||
return icon;
|
||||
}
|
||||
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
|
||||
});
|
||||
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
|
||||
|
||||
const targetClass = (target: MessageTarget) => ({
|
||||
'msg-target': true,
|
||||
@@ -155,7 +147,14 @@ onBeforeUnmount(() => {
|
||||
:data-id="message.id"
|
||||
>
|
||||
<div class="msg-icon">
|
||||
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
|
||||
<img
|
||||
class="general-icon"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="iconUrl"
|
||||
:alt="message.src.generalName"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export const DEFAULT_GENERAL_ICON_URL = '/image/icons/default.jpg';
|
||||
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = '/gateway/api/user-icons';
|
||||
|
||||
export type GeneralIconSource = {
|
||||
picture?: string | null;
|
||||
imageServer?: number | null;
|
||||
};
|
||||
|
||||
type GeneralIconOptions = {
|
||||
legacyBaseUrl?: string;
|
||||
userIconBaseUrl?: string;
|
||||
};
|
||||
|
||||
const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/u, '');
|
||||
|
||||
const encodeLegacyIconPath = (value: string): string =>
|
||||
value
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (segment === '.') return '%2E';
|
||||
if (segment === '..') return '%2E%2E';
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join('/');
|
||||
|
||||
const configuredUserIconBaseUrl = (): string =>
|
||||
import.meta.env?.VITE_GATEWAY_USER_ICON_BASE_URL?.trim() || DEFAULT_GATEWAY_USER_ICON_BASE_URL;
|
||||
|
||||
export const resolveGeneralIconUrl = (
|
||||
source: GeneralIconSource,
|
||||
{ legacyBaseUrl = '/image/icons', userIconBaseUrl = configuredUserIconBaseUrl() }: GeneralIconOptions = {}
|
||||
): string => {
|
||||
const picture = source.picture?.trim() || 'default.jpg';
|
||||
const baseUrl = source.imageServer ? userIconBaseUrl : legacyBaseUrl;
|
||||
const encodedPicture = source.imageServer ? encodeURIComponent(picture) : encodeLegacyIconPath(picture);
|
||||
return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`;
|
||||
};
|
||||
|
||||
export const resolveGeneralIconBackgroundImage = (source: GeneralIconSource, options?: GeneralIconOptions): string => {
|
||||
const resolved = resolveGeneralIconUrl(source, options);
|
||||
return `url(${JSON.stringify(resolved)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
||||
};
|
||||
|
||||
export const resolveMessageGeneralIconUrl = (
|
||||
icon: string | null | undefined,
|
||||
userIconBaseUrl = configuredUserIconBaseUrl()
|
||||
): string => {
|
||||
const normalized = icon?.trim();
|
||||
if (!normalized) {
|
||||
return DEFAULT_GENERAL_ICON_URL;
|
||||
}
|
||||
|
||||
const userIconMatch = /^\/?d_pic\/(.+)$/u.exec(normalized);
|
||||
if (userIconMatch) {
|
||||
return resolveGeneralIconUrl({ picture: userIconMatch[1], imageServer: 1 }, { userIconBaseUrl });
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return `${import.meta.env.BASE_URL}${normalized.replace(/^\/+/u, '')}`;
|
||||
};
|
||||
|
||||
export const useDefaultGeneralIcon = (event: Event): void => {
|
||||
if (
|
||||
typeof HTMLImageElement === 'undefined' ||
|
||||
!(event.currentTarget instanceof HTMLImageElement) ||
|
||||
event.currentTarget.dataset.generalIconFallbackSource === event.currentTarget.currentSrc
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.dataset.generalIconFallbackSource = event.currentTarget.currentSrc;
|
||||
event.currentTarget.src = DEFAULT_GENERAL_ICON_URL;
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type RankEntry = {
|
||||
@@ -47,10 +48,7 @@ const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<BestGeneralPayload | null>(null);
|
||||
|
||||
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
|
||||
const picture = entry.picture?.trim() || 'default.jpg';
|
||||
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => resolveGeneralIconUrl(entry);
|
||||
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
@@ -89,20 +87,10 @@ watch(viewMode, () => {
|
||||
</div>
|
||||
|
||||
<div class="view-selector" role="group" aria-label="장수 유형">
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:aria-pressed="viewMode === 'user'"
|
||||
@click="viewMode = 'user'"
|
||||
>
|
||||
<button class="legacy-button" type="button" :aria-pressed="viewMode === 'user'" @click="viewMode = 'user'">
|
||||
유저 보기
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:aria-pressed="viewMode === 'npc'"
|
||||
@click="viewMode = 'npc'"
|
||||
>
|
||||
<button class="legacy-button" type="button" :aria-pressed="viewMode === 'npc'" @click="viewMode = 'npc'">
|
||||
NPC 보기
|
||||
</button>
|
||||
</div>
|
||||
@@ -117,7 +105,14 @@ watch(viewMode, () => {
|
||||
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
|
||||
<div class="hall-rank legacy-bg2">{{ rank + 1 }}위</div>
|
||||
<div class="hall-img">
|
||||
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
|
||||
<img
|
||||
class="generalIcon"
|
||||
:src="imageUrl(entry)"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="entry.name"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
|
||||
{{ entry.nationName || '-' }}
|
||||
@@ -134,11 +129,7 @@ watch(viewMode, () => {
|
||||
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
|
||||
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(entry, index) in section.entries"
|
||||
:key="`${entry.itemKey}:${index}`"
|
||||
class="no-value"
|
||||
>
|
||||
<li v-for="(entry, index) in section.entries" :key="`${entry.itemKey}:${index}`" class="no-value">
|
||||
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
|
||||
<div class="hall-img">
|
||||
<img
|
||||
@@ -147,6 +138,7 @@ watch(viewMode, () => {
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="entry.owner.name"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type BoardArticle = Awaited<ReturnType<typeof trpc.board.getArticles.query>>[number];
|
||||
@@ -37,10 +38,11 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
|
||||
|
||||
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' ');
|
||||
|
||||
const iconPath = (article: BoardArticle): string => {
|
||||
const picture = article.authorPicture || 'default.jpg';
|
||||
return article.authorImageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const iconPath = (article: BoardArticle): string =>
|
||||
resolveGeneralIconUrl({
|
||||
picture: article.authorPicture,
|
||||
imageServer: article.authorImageServer,
|
||||
});
|
||||
|
||||
const refreshArticles = async () => {
|
||||
if (loading.value) {
|
||||
@@ -177,6 +179,7 @@ onMounted(() => {
|
||||
height="64"
|
||||
:src="iconPath(article)"
|
||||
:alt="`${article.authorName} 아이콘`"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div class="article-text">{{ article.content }}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
||||
@@ -93,10 +94,7 @@ const defenceTrainText = (value: number | null) => {
|
||||
if (value >= 60) return '○';
|
||||
return '△';
|
||||
};
|
||||
const generalImage = (general: General) => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const generalImage = (general: General): string => resolveGeneralIconUrl(general);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -270,7 +268,13 @@ const generalImage = (general: General) => {
|
||||
:data-general-wounded="general.injury"
|
||||
>
|
||||
<td class="icon-cell">
|
||||
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
|
||||
<img
|
||||
class="general-icon"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="generalImage(general)"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</td>
|
||||
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -45,10 +46,7 @@ const loadDirectory = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const imageUrl = (general: General): string => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/general/${picture}`;
|
||||
};
|
||||
const imageUrl = (general: General): string => resolveGeneralIconUrl(general, { legacyBaseUrl: '/image/general' });
|
||||
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -135,11 +133,20 @@ onMounted(() => {
|
||||
:data-npc-type="general.npcState"
|
||||
>
|
||||
<td class="center">
|
||||
<img class="general-icon" width="64" height="64" :src="imageUrl(general)" alt="" />
|
||||
<img
|
||||
class="general-icon"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="imageUrl(general)"
|
||||
alt=""
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
|
||||
<template v-if="general.ownerName"><br /><small>({{ general.ownerName }})</small></template>
|
||||
<template v-if="general.ownerName"
|
||||
><br /><small>({{ general.ownerName }})</small></template
|
||||
>
|
||||
</td>
|
||||
<td class="center">{{ general.age }}세</td>
|
||||
<td class="center">
|
||||
@@ -156,9 +163,7 @@ onMounted(() => {
|
||||
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0
|
||||
? injuredStat(general.leadership, general.injury)
|
||||
: general.leadership
|
||||
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type HallOption = {
|
||||
@@ -59,10 +60,7 @@ const selection = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const imageUrl = (entry: HallEntry): string => {
|
||||
const picture = entry.picture?.trim() || 'default.jpg';
|
||||
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const imageUrl = (entry: HallEntry): string => resolveGeneralIconUrl(entry);
|
||||
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
@@ -143,7 +141,14 @@ onMounted(loadOptions);
|
||||
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.generalId}:${rank}`">
|
||||
<div class="hall-rank legacy-bg2">{{ rank + 1 }}위</div>
|
||||
<div class="hall-img">
|
||||
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
|
||||
<img
|
||||
class="generalIcon"
|
||||
:src="imageUrl(entry)"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="entry.name"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.serverName"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionStore } from '../stores/session';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||
@@ -161,20 +162,8 @@ const isTrpcBusinessError = (value: unknown): boolean => {
|
||||
return Boolean(data && typeof data === 'object' && 'code' in data && typeof data.code === 'string');
|
||||
};
|
||||
|
||||
const npcImageUrl = (candidate: { picture: string | null; imageServer: number }): string => {
|
||||
const picture = candidate.picture ?? 'default.jpg';
|
||||
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
return candidate.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `/image/icons/${encodeURIComponent(picture)}`;
|
||||
};
|
||||
|
||||
const useDefaultNpcImage = (event: Event): void => {
|
||||
const image = event.currentTarget;
|
||||
if (image instanceof HTMLImageElement && !image.src.endsWith('/image/icons/default.jpg')) {
|
||||
image.src = '/image/icons/default.jpg';
|
||||
}
|
||||
};
|
||||
const npcImageUrl = (candidate: { picture: string | null; imageServer: number }): string =>
|
||||
resolveGeneralIconUrl(candidate);
|
||||
|
||||
const npcReservation = ref<PossessReservation | null>(null);
|
||||
const npcLoading = ref(false);
|
||||
@@ -806,7 +795,7 @@ onUnmounted(() => {
|
||||
:alt="`${npc.name} 얼굴`"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useDefaultNpcImage"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
@@ -935,7 +924,7 @@ onUnmounted(() => {
|
||||
:alt="`${general.name} 얼굴`"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useDefaultNpcImage"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
@@ -307,10 +308,9 @@ onMounted(() => {
|
||||
<div v-else class="general-table">
|
||||
<div class="portrait-cell">
|
||||
<img
|
||||
:src="
|
||||
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
|
||||
"
|
||||
:src="resolveGeneralIconUrl(data.general, { legacyBaseUrl: '/image/game' })"
|
||||
alt=""
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
<strong>{{ data.general.name }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
|
||||
|
||||
@@ -61,10 +62,7 @@ const chiefAssignments = computed(() => data.value?.chiefAssignments ?? {});
|
||||
const cityNameMap = computed(() => new Map((data.value?.cityAssignments ?? []).map((city) => [city.id, city.name])));
|
||||
const generalMap = computed(() => new Map((data.value?.generals ?? []).map((general) => [general.id, general])));
|
||||
|
||||
const imageUrl = (general: GeneralEntry | undefined): string => {
|
||||
const picture = general?.picture ?? 'default.jpg';
|
||||
return general?.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const imageBackground = (general: GeneralEntry | undefined): string => resolveGeneralIconBackgroundImage(general ?? {});
|
||||
const officerLocked = (value: number, level: number): boolean => (value & (1 << level)) !== 0;
|
||||
const chiefLocked = (level: number): boolean => officerLocked(data.value?.nation.chiefSet ?? 0, level);
|
||||
const cityOfficerLocked = (city: PersonnelResponse['cityAssignments'][number], level: number): boolean =>
|
||||
@@ -219,7 +217,7 @@ onMounted(() => void loadPersonnel());
|
||||
<td class="green-cell role-cell">{{ formatOfficerLevelText(level, nationLevel) }}</td>
|
||||
<td
|
||||
class="general-icon"
|
||||
:style="{ backgroundImage: `url('${imageUrl(chiefAssignments[level])}')` }"
|
||||
:style="{ backgroundImage: imageBackground(chiefAssignments[level]) }"
|
||||
/>
|
||||
<td class="chief-name">
|
||||
{{ chiefAssignments[level]?.name ?? '-' }}({{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
@@ -107,12 +108,7 @@ const clearPendingAction = (action: PendingSelectionAction): void => {
|
||||
const isIndeterminateTimeout = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
||||
const data = value.data;
|
||||
return Boolean(
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'code' in data &&
|
||||
data.code === 'TIMEOUT'
|
||||
);
|
||||
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string | null | undefined): string => {
|
||||
@@ -131,25 +127,14 @@ const shuffleNations = (source: Nation[]): Nation[] => {
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const userIconBaseUrl =
|
||||
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
const imageUrl = (candidate: Candidate): string =>
|
||||
candidate.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${candidate.picture}`
|
||||
: `/image/icons/${candidate.picture}`;
|
||||
const useFallbackImage = (event: Event): void => {
|
||||
const image = event.currentTarget as HTMLImageElement;
|
||||
if (image.dataset.fallbackApplied === 'true') return;
|
||||
image.dataset.fallbackApplied = 'true';
|
||||
image.src = '/image/icons/default.jpg';
|
||||
};
|
||||
const imageUrl = (candidate: Candidate): string => resolveGeneralIconUrl(candidate);
|
||||
|
||||
const personalityName = (key: string | null): string | null => {
|
||||
if (!key) return null;
|
||||
return personalities.value.find((entry) => entry.key === key)?.name ?? key;
|
||||
};
|
||||
const personalityInfo = (key: string | null): string =>
|
||||
key ? personalities.value.find((entry) => entry.key === key)?.info ?? '' : '';
|
||||
key ? (personalities.value.find((entry) => entry.key === key)?.info ?? '') : '';
|
||||
|
||||
const lightTextNationColors = new Set([
|
||||
'',
|
||||
@@ -294,8 +279,10 @@ onBeforeUnmount(() => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
현재 : {{ serverInfo.currentYear }}年 {{ serverInfo.currentMonth }}月
|
||||
(<span class="cyan">{{ serverInfo.tickMinutes }}분 턴</span> 서버)<br />
|
||||
현재 : {{ serverInfo.currentYear }}年 {{ serverInfo.currentMonth }}月 (<span class="cyan"
|
||||
>{{ serverInfo.tickMinutes }}분 턴</span
|
||||
>
|
||||
서버)<br />
|
||||
등록 장수 : 유저 {{ serverInfo.userGeneralCount }} / {{ serverInfo.maxGeneral }} 명 +
|
||||
<span class="cyan">NPC {{ serverInfo.npcGeneralCount }} 명</span>
|
||||
</td>
|
||||
@@ -319,7 +306,9 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
>
|
||||
<td class="invitation-nation">{{ nation.name }}</td>
|
||||
<td><div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div></td>
|
||||
<td>
|
||||
<div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -337,11 +326,7 @@ onBeforeUnmount(() => {
|
||||
<small v-else class="expired-text">- 만료 -</small>
|
||||
<br />
|
||||
<div class="card-holder">
|
||||
<article
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.uniqueName"
|
||||
class="general-card"
|
||||
>
|
||||
<article v-for="candidate in candidates" :key="candidate.uniqueName" class="general-card">
|
||||
<h4 class="legacy-bg1 with-border">{{ candidate.generalName }}</h4>
|
||||
<h4 class="portrait">
|
||||
<img
|
||||
@@ -349,7 +334,7 @@ onBeforeUnmount(() => {
|
||||
:alt="candidate.generalName"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useFallbackImage"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
@@ -398,7 +383,7 @@ onBeforeUnmount(() => {
|
||||
:alt="selectedCandidate.generalName"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useFallbackImage"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
@@ -486,12 +471,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="footer-banner with-border">
|
||||
<small>
|
||||
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD /
|
||||
<a
|
||||
href="https://sam.hided.net/wiki/hidche/credit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer"
|
||||
>Credit</a
|
||||
>
|
||||
</small>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||
@@ -159,10 +160,7 @@ const hideMemberPopup = () => {
|
||||
popupMember.value = null;
|
||||
};
|
||||
|
||||
const iconPath = (troop: Troop): string => {
|
||||
const picture = troop.leader?.picture || 'default.jpg';
|
||||
return troop.leader?.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
||||
|
||||
const formatTurn = (turnTime: string | null): string => {
|
||||
if (!turnTime) {
|
||||
@@ -212,6 +210,7 @@ onMounted(() => {
|
||||
width="64"
|
||||
:src="iconPath(troop)"
|
||||
:alt="`${troop.leader?.name ?? '부대장'} 아이콘`"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</div>
|
||||
<div class="troopLeaderName">{{ troop.leader?.name ?? '알 수 없음' }}</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_GENERAL_ICON_URL,
|
||||
resolveGeneralIconBackgroundImage,
|
||||
resolveGeneralIconUrl,
|
||||
resolveMessageGeneralIconUrl,
|
||||
} from '../src/utils/generalIcon.ts';
|
||||
|
||||
void describe('generalIcon', () => {
|
||||
void it('routes user icons through the gateway and encodes the complete filename', () => {
|
||||
assert.equal(
|
||||
resolveGeneralIconUrl(
|
||||
{ picture: '계정 icon/../1.jpg', imageServer: 1 },
|
||||
{ userIconBaseUrl: '/gateway/api/user-icons/' }
|
||||
),
|
||||
'/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon%2F..%2F1.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
void it('preserves each view legacy base for non-user icons', () => {
|
||||
assert.equal(
|
||||
resolveGeneralIconUrl({ picture: '22.jpg', imageServer: 0 }, { legacyBaseUrl: '/image/general/' }),
|
||||
'/image/general/22.jpg'
|
||||
);
|
||||
assert.equal(
|
||||
resolveGeneralIconUrl({ picture: '22.jpg', imageServer: 0 }, { legacyBaseUrl: '/image/game' }),
|
||||
'/image/game/22.jpg'
|
||||
);
|
||||
assert.equal(
|
||||
resolveGeneralIconUrl({ picture: '장수/관우 1.png', imageServer: 0 }),
|
||||
'/image/icons/%EC%9E%A5%EC%88%98/%EA%B4%80%EC%9A%B0%201.png'
|
||||
);
|
||||
assert.equal(
|
||||
resolveGeneralIconUrl({ picture: '../secret.png', imageServer: 0 }),
|
||||
'/image/icons/%2E%2E/secret.png'
|
||||
);
|
||||
});
|
||||
|
||||
void it('uses a deterministic default and safe layered fallback for CSS backgrounds', () => {
|
||||
assert.equal(resolveGeneralIconUrl({ picture: null, imageServer: 0 }), '/image/icons/default.jpg');
|
||||
assert.equal(
|
||||
resolveGeneralIconBackgroundImage(
|
||||
{ picture: 'custom.jpg', imageServer: 1 },
|
||||
{ userIconBaseUrl: '/gateway/api/user-icons' }
|
||||
),
|
||||
'url("/gateway/api/user-icons/custom.jpg"), url("/image/icons/default.jpg")'
|
||||
);
|
||||
});
|
||||
|
||||
void it('translates legacy message d_pic references without changing absolute or external icons', () => {
|
||||
assert.equal(
|
||||
resolveMessageGeneralIconUrl('d_pic/user name.jpg', '/gateway/api/user-icons/'),
|
||||
'/gateway/api/user-icons/user%20name.jpg'
|
||||
);
|
||||
assert.equal(resolveMessageGeneralIconUrl('/image/icons/22.jpg'), '/image/icons/22.jpg');
|
||||
assert.equal(resolveMessageGeneralIconUrl('https://cdn.example/icon.jpg'), 'https://cdn.example/icon.jpg');
|
||||
assert.equal(resolveMessageGeneralIconUrl(''), DEFAULT_GENERAL_ICON_URL);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type { GatewayApiContext } from '../context.js';
|
||||
import { procedure, router } from '../trpc.js';
|
||||
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
|
||||
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
@@ -37,13 +38,15 @@ const decodeImage = (input: string): Buffer => {
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const sameUtcDate = (left: Date, right: Date): boolean =>
|
||||
left.getUTCFullYear() === right.getUTCFullYear() &&
|
||||
left.getUTCMonth() === right.getUTCMonth() &&
|
||||
left.getUTCDate() === right.getUTCDate();
|
||||
const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
export const kstDayStart = (value: Date): Date => {
|
||||
const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS);
|
||||
return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS);
|
||||
};
|
||||
|
||||
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -59,8 +62,34 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
|
||||
};
|
||||
|
||||
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
|
||||
if (user.imageServer !== 1 || user.picture === 'default.jpg') return null;
|
||||
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`;
|
||||
const icon = resolveEffectiveAccountIcon(user);
|
||||
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
|
||||
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
|
||||
};
|
||||
|
||||
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
|
||||
(await ctx.profileStatus.listLobbyProfiles({ userId }))
|
||||
.filter(
|
||||
(profile) => (profile.status === 'RUNNING' || profile.status === 'PREOPEN') && profile.runtime.apiRunning
|
||||
)
|
||||
.map(({ profileName, profile, apiPort, korName }) => ({
|
||||
profileName,
|
||||
profile,
|
||||
apiPort,
|
||||
korName,
|
||||
}));
|
||||
|
||||
const publishIconFlush = async (
|
||||
ctx: GatewayApiContext,
|
||||
userId: string,
|
||||
reason: 'account-icon-changed' | 'account-icon-deleted'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await ctx.flushPublisher.publishUserFlush(userId, reason);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const accountRouter = router({
|
||||
@@ -136,6 +165,7 @@ export const accountRouter = router({
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const now = new Date();
|
||||
assertIconChangeAvailable(user, now);
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
|
||||
@@ -154,17 +184,56 @@ export const accountRouter = router({
|
||||
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
|
||||
await fs.mkdir(ctx.userIconDir, { recursive: true });
|
||||
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
|
||||
await ctx.users.updateIcon(user.id, filename, 1, now);
|
||||
let revision: string | null;
|
||||
try {
|
||||
revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true);
|
||||
} catch (error) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw error;
|
||||
}
|
||||
if (!revision) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
||||
revision,
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const now = new Date();
|
||||
assertIconChangeAvailable(user, now);
|
||||
await ctx.users.updateIcon(user.id, 'default.jpg', 0, now);
|
||||
return { ok: true, iconUrl: null };
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false);
|
||||
if (!revision) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
revision,
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
prepareIconSync: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
return {
|
||||
iconUrl: buildIconUrl(ctx, user),
|
||||
projection: resolveEffectiveAccountIcon(user),
|
||||
profiles: await listIconSyncProfiles(ctx, user.id),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -260,16 +260,17 @@ const zServerRestriction = z.object({
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
const zSanctionsPatch = z.object({
|
||||
bannedUntil: z.string().datetime().nullable().optional(),
|
||||
mutedUntil: z.string().datetime().nullable().optional(),
|
||||
suspendedUntil: z.string().datetime().nullable().optional(),
|
||||
warningCount: z.number().int().min(0).nullable().optional(),
|
||||
flags: z.array(z.string().min(1)).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
profileIconResetAt: z.string().datetime().nullable().optional(),
|
||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||
});
|
||||
const zSanctionsPatch = z
|
||||
.object({
|
||||
bannedUntil: z.string().datetime().nullable().optional(),
|
||||
mutedUntil: z.string().datetime().nullable().optional(),
|
||||
suspendedUntil: z.string().datetime().nullable().optional(),
|
||||
warningCount: z.number().int().min(0).nullable().optional(),
|
||||
flags: z.array(z.string().min(1)).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zLocalAccountInput = z.object({
|
||||
username: z.string().min(2).max(32),
|
||||
@@ -332,8 +333,6 @@ const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): Use
|
||||
applyField('warningCount', patch.warningCount);
|
||||
applyField('flags', patch.flags);
|
||||
applyField('notes', patch.notes);
|
||||
applyField('profileIconResetAt', patch.profileIconResetAt);
|
||||
|
||||
if (patch.serverRestrictions !== undefined) {
|
||||
if (patch.serverRestrictions === null) {
|
||||
delete next.serverRestrictions;
|
||||
@@ -495,6 +494,7 @@ export const adminRouter = router({
|
||||
oauthType: user.oauthType,
|
||||
oauthId: user.oauthId,
|
||||
email: user.email,
|
||||
profileIconResetAt: user.profileIconResetAt,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}),
|
||||
@@ -611,12 +611,22 @@ export const adminRouter = router({
|
||||
message: 'User not found.',
|
||||
});
|
||||
}
|
||||
const next = applySanctionsPatch(user.sanctions, {
|
||||
profileIconResetAt: new Date().toISOString(),
|
||||
});
|
||||
await ctx.users.updateSanctions(input.userId, next);
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset');
|
||||
return { profileIconResetAt: next.profileIconResetAt };
|
||||
const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date());
|
||||
if (!profileIconResetAt) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'User not found.',
|
||||
});
|
||||
}
|
||||
let flushPublished = true;
|
||||
try {
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset', {
|
||||
iconRevision: profileIconResetAt,
|
||||
});
|
||||
} catch {
|
||||
flushPublished = false;
|
||||
}
|
||||
return { profileIconResetAt, flushPublished };
|
||||
}),
|
||||
forceDelete: userAdminProcedure
|
||||
.input(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { isCanonicalIsoTimestamp } from '@sammo-ts/common';
|
||||
|
||||
import type { UserRepository } from './userRepository.js';
|
||||
import { resolveEffectiveAccountIcon } from './accountIconProjection.js';
|
||||
|
||||
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1';
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
const parseUserIds = (body: unknown): string[] | null => {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return null;
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
if (Object.keys(record).length !== 1 || !Array.isArray(record.userIds)) {
|
||||
return null;
|
||||
}
|
||||
if (record.userIds.length === 0 || record.userIds.length > 500) {
|
||||
return null;
|
||||
}
|
||||
const userIds = record.userIds.filter(
|
||||
(value): value is string => typeof value === 'string' && value.length > 0 && value.length <= 128
|
||||
);
|
||||
return userIds.length === record.userIds.length && new Set(userIds).size === userIds.length ? userIds : null;
|
||||
};
|
||||
|
||||
export const registerAccountIconInternalRoute = (
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
users: UserRepository;
|
||||
secret: string;
|
||||
}
|
||||
): void => {
|
||||
app.get<{ Params: { userId: string } }>('/internal/account-icons/:userId', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const user = await options.users.findById(request.params.userId);
|
||||
if (!user) {
|
||||
await reply.status(404).send({ ok: false, error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await reply.send(resolveEffectiveAccountIcon(user));
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>('/internal/account-icon-resets', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const userIds = parseUserIds(request.body);
|
||||
if (!userIds) {
|
||||
await reply.status(400).send({ ok: false, error: 'invalid_request' });
|
||||
return;
|
||||
}
|
||||
const users = await options.users.findByIds(userIds);
|
||||
const byId = new Map(users.map((user) => [user.id, user]));
|
||||
const resets = userIds.flatMap((userId) => {
|
||||
const user = byId.get(userId);
|
||||
const resetRevision = user?.profileIconResetAt;
|
||||
if (!user || !resetRevision || !isCanonicalIsoTimestamp(resetRevision)) {
|
||||
return [];
|
||||
}
|
||||
return [{ userId, resetRevision, current: resolveEffectiveAccountIcon(user) }];
|
||||
});
|
||||
await reply.send({ resets });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolveAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { UserRecord } from './userRepository.js';
|
||||
|
||||
export const resolveEffectiveAccountIcon = (
|
||||
user: Pick<
|
||||
UserRecord,
|
||||
'createdAt' | 'picture' | 'imageServer' | 'iconUpdatedAt' | 'iconRevision' | 'profileIconResetAt'
|
||||
>
|
||||
): AccountIconProjection =>
|
||||
resolveAccountIconProjection({
|
||||
createdAt: user.createdAt,
|
||||
picture: user.picture,
|
||||
imageServer: user.imageServer,
|
||||
...(user.iconUpdatedAt ? { iconUpdatedAt: user.iconUpdatedAt } : {}),
|
||||
...(user.iconRevision ? { iconRevision: user.iconRevision } : {}),
|
||||
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
|
||||
});
|
||||
@@ -2,10 +2,11 @@ export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
export interface GatewayFlushPublisher {
|
||||
publishUserFlush(userId: string, reason?: string): Promise<void>;
|
||||
publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
@@ -17,11 +18,12 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
async publishUserFlush(userId: string, reason?: string): Promise<void> {
|
||||
async publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void> {
|
||||
const payload: GatewayUserFlushEvent = {
|
||||
userId,
|
||||
flushedAt: new Date().toISOString(),
|
||||
reason,
|
||||
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async findByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
const requested = new Set(ids);
|
||||
return [...usersByName.values()].filter((user) => requested.has(user.id));
|
||||
},
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
return usersByName.get(username) ?? null;
|
||||
},
|
||||
@@ -149,11 +153,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
user.iconUpdatedAt = updatedAt.toISOString();
|
||||
user.iconRevision = updatedAt.toISOString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) {
|
||||
return null;
|
||||
}
|
||||
const previousRevision = Math.max(
|
||||
new Date(user.createdAt).getTime(),
|
||||
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
|
||||
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
|
||||
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
|
||||
);
|
||||
const revision = new Date(Math.max(updatedAt.getTime(), previousRevision + 1)).toISOString();
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
if (consumeDailyQuota) {
|
||||
user.iconUpdatedAt = updatedAt.toISOString();
|
||||
}
|
||||
user.iconRevision = revision;
|
||||
return revision;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
const previousRevision = Math.max(
|
||||
new Date(user.createdAt).getTime(),
|
||||
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
|
||||
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
|
||||
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
|
||||
);
|
||||
const revision = new Date(Math.max(requestedAt.getTime(), previousRevision + 1)).toISOString();
|
||||
user.iconRevision = revision;
|
||||
user.profileIconResetAt = revision;
|
||||
return revision;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||
import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js';
|
||||
@@ -44,6 +44,8 @@ const mapUser = (row: {
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt: Date | null;
|
||||
iconRevision: Date | null;
|
||||
profileIconResetAt: Date | null;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt: Date | null;
|
||||
privacyAcceptedAt: Date | null;
|
||||
@@ -65,6 +67,8 @@ const mapUser = (row: {
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||
iconRevision: row.iconRevision?.toISOString(),
|
||||
profileIconResetAt: row.profileIconResetAt?.toISOString(),
|
||||
thirdPartyUse: row.thirdPartyUse,
|
||||
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
||||
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
||||
@@ -91,6 +95,15 @@ export const createPostgresUserRepository = (
|
||||
});
|
||||
return row ? mapUser(row) : null;
|
||||
},
|
||||
async findByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = await prisma.appUser.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
return rows.map(mapUser);
|
||||
},
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const row = await prisma.appUser.findUnique({
|
||||
where: {
|
||||
@@ -219,9 +232,81 @@ export const createPostgresUserRepository = (
|
||||
picture,
|
||||
imageServer,
|
||||
iconUpdatedAt: updatedAt,
|
||||
iconRevision: updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
async updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): Promise<string | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||
UPDATE "app_user"
|
||||
SET
|
||||
"picture" = ${picture},
|
||||
"image_server" = ${imageServer},
|
||||
"icon_updated_at" = CASE
|
||||
WHEN ${consumeDailyQuota} THEN ${updatedAt}
|
||||
ELSE "icon_updated_at"
|
||||
END,
|
||||
"icon_revision" = GREATEST(
|
||||
${updatedAt},
|
||||
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
|
||||
)
|
||||
WHERE "id" = ${userId}
|
||||
AND (
|
||||
"picture" = 'default.jpg'
|
||||
OR "icon_updated_at" IS NULL
|
||||
OR "icon_updated_at" < ${dayStart}
|
||||
)
|
||||
RETURNING "icon_revision" AS "iconRevision"
|
||||
`);
|
||||
return rows[0]?.iconRevision.toISOString() ?? null;
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
iconRevision: Date | null;
|
||||
iconUpdatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
profileIconResetAt: Date | null;
|
||||
}>
|
||||
>(GatewayPrisma.sql`
|
||||
SELECT
|
||||
"icon_revision" AS "iconRevision",
|
||||
"icon_updated_at" AS "iconUpdatedAt",
|
||||
"created_at" AS "createdAt",
|
||||
"profile_icon_reset_at" AS "profileIconResetAt"
|
||||
FROM "app_user"
|
||||
WHERE "id" = ${userId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const previous = Math.max(
|
||||
row.createdAt.getTime(),
|
||||
row.iconUpdatedAt?.getTime() ?? 0,
|
||||
row.iconRevision?.getTime() ?? 0,
|
||||
row.profileIconResetAt?.getTime() ?? 0
|
||||
);
|
||||
const revision = new Date(Math.max(requestedAt.getTime(), previous + 1));
|
||||
await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
iconRevision: revision,
|
||||
profileIconResetAt: revision,
|
||||
},
|
||||
});
|
||||
return revision.toISOString();
|
||||
});
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UserRecord {
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt?: string;
|
||||
privacyAcceptedAt?: string;
|
||||
@@ -42,7 +44,6 @@ export interface UserSanctions {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export interface CreateUserInput {
|
||||
|
||||
export interface UserRepository {
|
||||
findById(id: string): Promise<UserRecord | null>;
|
||||
findByIds(ids: string[]): Promise<UserRecord[]>;
|
||||
findByUsername(username: string): Promise<UserRecord | null>;
|
||||
findByDisplayName(displayName: string): Promise<UserRecord | null>;
|
||||
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
|
||||
@@ -102,6 +104,15 @@ export interface UserRepository {
|
||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
|
||||
updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): Promise<string | null>;
|
||||
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
|
||||
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
||||
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface GatewayApiConfig {
|
||||
sessionTtlSeconds: number;
|
||||
gameSessionTtlSeconds: number;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
oauthSessionTtlSeconds: number;
|
||||
kakaoRestKey: string;
|
||||
kakaoAdminKey?: string;
|
||||
@@ -36,6 +37,7 @@ export interface GatewayOrchestratorConfig {
|
||||
dbSchema: string;
|
||||
redisKeyPrefix: string;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
orchestratorReconcileIntervalMs: number;
|
||||
orchestratorScheduleIntervalMs: number;
|
||||
orchestratorBuildIntervalMs: number;
|
||||
@@ -64,9 +66,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
}
|
||||
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port: parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
||||
port,
|
||||
trpcPath: env.GATEWAY_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
|
||||
redisKeyPrefix,
|
||||
@@ -78,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
'GAME_SESSION_TTL_SECONDS'
|
||||
),
|
||||
gameTokenSecret: secret,
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${port}`,
|
||||
oauthSessionTtlSeconds: parseNumberWithFallback(
|
||||
env.OAUTH_SESSION_TTL_SECONDS,
|
||||
10 * 60,
|
||||
@@ -133,10 +137,12 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
|
||||
throw new Error('GAME_TOKEN_SECRET is required for game server processes.');
|
||||
}
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const gatewayPort = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
return {
|
||||
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
|
||||
redisKeyPrefix,
|
||||
gameTokenSecret: secret,
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${gatewayPort}`,
|
||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||
15000,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GatewayProcessConfig {
|
||||
workspaceRoot: string;
|
||||
redisKeyPrefix: string;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
baseEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -350,6 +351,7 @@ export const buildProcessDefinitions = (
|
||||
GAME_UPLOAD_PATH: `/${profile.profile}/api/uploads`,
|
||||
GATEWAY_REDIS_PREFIX: config.redisKeyPrefix,
|
||||
GAME_TOKEN_SECRET: config.gameTokenSecret,
|
||||
GATEWAY_INTERNAL_API_URL: config.gatewayInternalApiUrl,
|
||||
};
|
||||
const daemonEnv = {
|
||||
...baseEnv,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const createGatewayOrchestrator = (
|
||||
workspaceRoot,
|
||||
redisKeyPrefix: config.redisKeyPrefix,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
gatewayInternalApiUrl: config.gatewayInternalApiUrl,
|
||||
baseEnv,
|
||||
},
|
||||
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
||||
|
||||
const zUsername = z
|
||||
.string()
|
||||
@@ -655,6 +656,7 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||
const payload = {
|
||||
version: 1,
|
||||
profile: gameSession.profile,
|
||||
@@ -665,8 +667,10 @@ export const appRouter = router({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
picture: user.picture,
|
||||
imageServer: user.imageServer,
|
||||
picture: accountIcon.picture,
|
||||
imageServer: accountIcon.imageServer,
|
||||
iconUpdatedAt: accountIcon.revision,
|
||||
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
|
||||
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -78,6 +79,10 @@ export const createGatewayApiServer = async () => {
|
||||
prefix: '/user-icons/',
|
||||
decorateReply: false,
|
||||
});
|
||||
registerAccountIconInternalRoute(app, {
|
||||
users,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
|
||||
|
||||
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const userId = 'e72680fd-0aed-4fdd-80d9-24f78d55676c';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
integration('account icon daily PostgreSQL CAS', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
initialized = true;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'icon-cas-integration',
|
||||
displayName: '아이콘 CAS 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (initialized) {
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('allows exactly one concurrent update in the same KST day', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const updatedAt = new Date('2026-07-31T15:00:00.000Z');
|
||||
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
|
||||
const results = await Promise.all([
|
||||
users.updateIconForDay(userId, 'first.png', 1, updatedAt, kstDayStart, true),
|
||||
users.updateIconForDay(userId, 'second.png', 1, updatedAt, kstDayStart, true),
|
||||
]);
|
||||
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
picture: expect.stringMatching(/^(first|second)\.png$/),
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a default icon to upload again while revisions stay strictly increasing', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const frozenNow = new Date('2026-08-01T03:00:00.000Z');
|
||||
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-30T14:59:59.000Z'),
|
||||
iconRevision: new Date('2026-08-01T03:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const deletedRevision = await users.updateIconForDay(userId, 'default.jpg', 0, frozenNow, kstDayStart, false);
|
||||
const uploadedRevision = await users.updateIconForDay(userId, 'again.png', 1, frozenNow, kstDayStart, true);
|
||||
|
||||
expect(deletedRevision).toBe('2026-08-01T03:00:00.001Z');
|
||||
expect(uploadedRevision).toBe('2026-08-01T03:00:00.002Z');
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
picture: 'again.png',
|
||||
iconUpdatedAt: frozenNow,
|
||||
iconRevision: new Date('2026-08-01T03:00:00.002Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses UTC 15:00 as the KST date boundary', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'same-day.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-31T14:59:59.000Z'),
|
||||
iconRevision: new Date('2026-07-31T14:59:59.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
users.updateIconForDay(
|
||||
userId,
|
||||
'blocked.png',
|
||||
1,
|
||||
new Date('2026-07-31T14:59:59.999Z'),
|
||||
new Date('2026-07-30T15:00:00.000Z'),
|
||||
true
|
||||
)
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
users.updateIconForDay(
|
||||
userId,
|
||||
'allowed.png',
|
||||
1,
|
||||
new Date('2026-07-31T15:00:00.000Z'),
|
||||
new Date('2026-07-31T15:00:00.000Z'),
|
||||
true
|
||||
)
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('serializes administrator reset revisions in a dedicated column', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const frozenNow = new Date('2026-08-02T00:00:00.000Z');
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'custom.png',
|
||||
imageServer: 1,
|
||||
iconRevision: frozenNow,
|
||||
profileIconResetAt: null,
|
||||
sanctions: { warningCount: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const first = await users.resetProfileIcon(userId, frozenNow);
|
||||
const second = await users.resetProfileIcon(userId, frozenNow);
|
||||
|
||||
expect(first).toBe('2026-08-02T00:00:00.001Z');
|
||||
expect(second).toBe('2026-08-02T00:00:00.002Z');
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
profileIconResetAt: new Date('2026-08-02T00:00:00.002Z'),
|
||||
iconRevision: new Date('2026-08-02T00:00:00.002Z'),
|
||||
sanctions: { warningCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerAccountIconInternalRoute } from '../src/auth/accountIconInternalRoute.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
|
||||
const secret = 'gateway-test-secret';
|
||||
const token = createHmac('sha256', secret).update('sammo:account-icon-source:v1').digest('hex');
|
||||
|
||||
describe('account icon internal route', () => {
|
||||
const app = fastify();
|
||||
const users = createInMemoryUserRepository();
|
||||
let userId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!app.hasRoute({ method: 'GET', url: '/internal/account-icons/:userId' })) {
|
||||
registerAccountIconInternalRoute(app, { users, secret });
|
||||
}
|
||||
const user = await users.createUser({
|
||||
username: `internal-${randomUUID()}`,
|
||||
password: 'password',
|
||||
displayName: `내부-${randomUUID()}`,
|
||||
});
|
||||
userId = user.id;
|
||||
await users.updateIcon(userId, 'latest.png', 1, new Date('2026-07-31T09:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (userId) {
|
||||
await users.deleteUser(userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires the purpose-derived token and exposes only the projection', async () => {
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${userId}`,
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${userId}`,
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
});
|
||||
expect(Object.keys(response.json()).sort()).toEqual(['imageServer', 'picture', 'revision']);
|
||||
});
|
||||
|
||||
it('returns 404 for a missing account without leaking account fields', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${randomUUID()}`,
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
|
||||
});
|
||||
|
||||
it('returns the durable reset marker even after a newer ordinary icon change', async () => {
|
||||
const resetRevision = await users.resetProfileIcon(userId, new Date('2099-07-31T09:00:00.001Z'));
|
||||
await users.updateIcon(userId, 'newer.png', 1, new Date('2099-07-31T09:00:00.002Z'));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/account-icon-resets',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
payload: { userIds: [userId, randomUUID()] },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({
|
||||
resets: [
|
||||
{
|
||||
userId,
|
||||
resetRevision,
|
||||
current: {
|
||||
revision: '2099-07-31T09:00:00.002Z',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/account-icon-resets',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
payload: { userIds: [userId], extra: true },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const buildCaller = async (
|
||||
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
@@ -79,8 +79,12 @@ const buildCaller = async (
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher: {
|
||||
publishUserFlush: async (userId, reason) => {
|
||||
flushes.push({ userId, reason });
|
||||
publishUserFlush: async (userId, reason, metadata) => {
|
||||
flushes.push({
|
||||
userId,
|
||||
reason,
|
||||
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -397,4 +401,37 @@ describe('admin role non-escalation', () => {
|
||||
{ userId: target.id, reason: 'admin-server-restriction' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps profile icon reset revisions monotonic and outside generic sanction patches', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
const target = await harness.users.createUser({
|
||||
username: 'icon-reset-target',
|
||||
password: 'secretpass',
|
||||
displayName: 'Icon Reset Target',
|
||||
});
|
||||
const frozenNow = new Date(target.createdAt);
|
||||
await harness.users.updateIcon(target.id, 'custom.png', 1, frozenNow);
|
||||
const first = await harness.users.resetProfileIcon(target.id, frozenNow);
|
||||
const second = await harness.users.resetProfileIcon(target.id, frozenNow);
|
||||
|
||||
expect(new Date(first!).getTime()).toBe(new Date(target.createdAt).getTime() + 1);
|
||||
expect(new Date(second!).getTime()).toBe(new Date(first!).getTime() + 1);
|
||||
await expect(
|
||||
harness.caller.admin.users.updateSanctions({
|
||||
userId: target.id,
|
||||
patch: {
|
||||
profileIconResetAt: null,
|
||||
},
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
|
||||
const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id });
|
||||
expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime());
|
||||
expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt);
|
||||
expect(harness.flushes.at(-1)).toEqual({
|
||||
userId: target.id,
|
||||
reason: 'admin-profile-icon-reset',
|
||||
iconRevision: result.profileIconResetAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -16,14 +16,25 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
|
||||
const buildCaller = (
|
||||
options: {
|
||||
userIconDir?: string;
|
||||
localAccountGraceDays?: number;
|
||||
flushError?: Error;
|
||||
profileListError?: Error;
|
||||
} = {}
|
||||
) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const sessions = new InMemoryGatewaySessionService({
|
||||
sessionTtlSeconds: 3600,
|
||||
gameSessionTtlSeconds: 600,
|
||||
});
|
||||
const flushPublisher = {
|
||||
publishUserFlush: async () => {},
|
||||
publishUserFlush: vi.fn(async () => {
|
||||
if (options.flushError) {
|
||||
throw options.flushError;
|
||||
}
|
||||
}),
|
||||
};
|
||||
const oauthSessions = new InMemoryOAuthSessionStore();
|
||||
const kakaoClient = {
|
||||
@@ -139,6 +150,11 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
||||
color: '#fff',
|
||||
}))
|
||||
);
|
||||
if (options.profileListError) {
|
||||
profileStatus.listLobbyProfiles = async () => {
|
||||
throw options.profileListError;
|
||||
};
|
||||
}
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const sealPassword = (password: string) => {
|
||||
@@ -187,6 +203,7 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
||||
oauthSessions,
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher,
|
||||
sealPassword,
|
||||
setSessionHeader: (sessionToken: string) => {
|
||||
requestHeaders['x-session-token'] = sessionToken;
|
||||
@@ -576,6 +593,7 @@ describe('gateway auth flow', () => {
|
||||
roles: ['user', 'latest-role'],
|
||||
picture: 'latest-owner.webp',
|
||||
imageServer: 3,
|
||||
iconUpdatedAt: '2026-07-30T12:00:00.000Z',
|
||||
canUseGeneralPicture: false,
|
||||
});
|
||||
expect(payload?.sanctions).toMatchObject({
|
||||
@@ -668,7 +686,9 @@ describe('account self service', () => {
|
||||
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const { caller, users, sessions, flushPublisher } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-self',
|
||||
password: 'current-password',
|
||||
@@ -692,7 +712,9 @@ describe('account self service', () => {
|
||||
const updated = await users.findById(user.id);
|
||||
|
||||
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
|
||||
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
|
||||
expect(updated?.imageServer).toBe(1);
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
|
||||
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
|
||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
@@ -701,4 +723,177 @@ describe('account self service', () => {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('atomically allows only one icon change per KST day and removes the losing file', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const user = await users.createUser({
|
||||
username: 'icon-race',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: '#556677',
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const attempts = await Promise.allSettled(
|
||||
[1, 2].map(() =>
|
||||
caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
||||
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
||||
expect(await fs.readdir(iconDir)).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes an account icon deletion with selectable running profiles', async () => {
|
||||
const { caller, users, sessions, flushPublisher } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'icon-delete',
|
||||
password: 'current-password',
|
||||
});
|
||||
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-30T12:00:00.000Z'));
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
const result = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||
const updated = await users.findById(user.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
|
||||
});
|
||||
expect(updated).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
|
||||
});
|
||||
|
||||
it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: '#667788',
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-31T14:59:59.000Z'));
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const user = await users.createUser({
|
||||
username: 'icon-kst',
|
||||
password: 'current-password',
|
||||
});
|
||||
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
|
||||
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
|
||||
|
||||
const changed = await caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
||||
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not commit an icon when profile discovery fails before mutation', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-profile-failure-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
profileListError: new Error('profile unavailable'),
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-profile-failure',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#778899' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await expect(
|
||||
caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
).rejects.toThrow('profile unavailable');
|
||||
expect(await fs.readdir(iconDir)).toEqual([]);
|
||||
expect(await users.findById(user.id)).toMatchObject({
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a recoverable success when flush publication fails after commit', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-flush-failure-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
flushError: new Error('redis unavailable'),
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-flush-failure',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#8899aa' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const changed = await caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
expect(changed.flushPublished).toBe(false);
|
||||
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
|
||||
|
||||
await expect(caller.account.prepareIconSync({ sessionToken: session.sessionToken })).resolves.toMatchObject(
|
||||
{
|
||||
projection: {
|
||||
revision: changed.revision,
|
||||
imageServer: 1,
|
||||
},
|
||||
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ const createHarness = (
|
||||
workspaceRoot: '/srv/sammo',
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
|
||||
@@ -102,6 +102,7 @@ describe('buildProcessDefinitions', () => {
|
||||
workspaceRoot: '/srv/sammo/main',
|
||||
redisKeyPrefix: 'sammo:gateway',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
};
|
||||
|
||||
it('runs a built profile from its commit worktree', () => {
|
||||
@@ -114,6 +115,7 @@ describe('buildProcessDefinitions', () => {
|
||||
GAME_PROFILE_NAME: 'che:2',
|
||||
GAME_TRPC_PATH: '/che/api/trpc',
|
||||
GAME_API_EVENTS_PATH: '/che/api/events',
|
||||
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
|
||||
GAME_UPLOAD_PATH: '/che/api/uploads',
|
||||
});
|
||||
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||
|
||||
@@ -62,6 +62,7 @@ const createHarness = (
|
||||
workspaceRoot: '/srv/sammo',
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
|
||||
@@ -49,9 +49,7 @@ describe('password credential compatibility', () => {
|
||||
password: 'current-password',
|
||||
});
|
||||
const userSalt = 'ref-user-salt';
|
||||
const browserHash = createHash('sha512')
|
||||
.update(`${globalSalt}current-password${globalSalt}`)
|
||||
.digest('hex');
|
||||
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: 500,
|
||||
path,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
};
|
||||
|
||||
const profileInputAt = (body: string, index: number): string | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, { profile?: unknown; json?: { profile?: unknown } }>;
|
||||
const input = parsed[String(index)];
|
||||
const profile = input?.profile ?? input?.json?.profile;
|
||||
return typeof profile === 'string' ? profile : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type FixtureOptions = {
|
||||
failHweAdjustOnce?: boolean;
|
||||
delayHweAdjust?: boolean;
|
||||
};
|
||||
|
||||
const activeProfiles = [
|
||||
{
|
||||
profileName: 'che:903',
|
||||
profile: 'che',
|
||||
apiPort: 15003,
|
||||
korName: 'CHE 서버',
|
||||
},
|
||||
{
|
||||
profileName: 'hwe:903',
|
||||
profile: 'hwe',
|
||||
apiPort: 15015,
|
||||
korName: '훼',
|
||||
},
|
||||
];
|
||||
|
||||
const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
let deleteIconCount = 0;
|
||||
let hweAdjustCount = 0;
|
||||
const operations = new Map<string, string[]>([
|
||||
['che:903', []],
|
||||
['hwe:903', []],
|
||||
]);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'account-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const body = route.request().postData() ?? '';
|
||||
const results = operationNames(route).map((operation, index) => {
|
||||
if (operation === 'account.get') {
|
||||
return response({
|
||||
id: 'account-user',
|
||||
username: 'account-user',
|
||||
displayName: '계정 사용자',
|
||||
roles: ['user'],
|
||||
oauthType: 'NONE',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
iconUrl: '/gateway/api/user-icons/old.png',
|
||||
thirdPartyUse: false,
|
||||
deleteAfter: null,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.changeIcon') {
|
||||
return response({
|
||||
ok: true,
|
||||
iconUrl: '/gateway/api/user-icons/new.png',
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
profiles: activeProfiles,
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.deleteIcon') {
|
||||
deleteIconCount += 1;
|
||||
return response({
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
revision: '2026-07-31T09:00:00.002Z',
|
||||
profiles: [activeProfiles[1]],
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.prepareIconSync') {
|
||||
return response({
|
||||
iconUrl: '/gateway/api/user-icons/new.png',
|
||||
projection: {
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
picture: 'new.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
profiles: activeProfiles,
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.issueGameSession') {
|
||||
const profileName = profileInputAt(body, index);
|
||||
expect(profileName === 'che:903' || profileName === 'hwe:903').toBe(true);
|
||||
if (profileName !== 'che:903' && profileName !== 'hwe:903') {
|
||||
throw new Error('issueGameSession profile input was not encoded in the request.');
|
||||
}
|
||||
operations.get(profileName)?.push('issueGameSession');
|
||||
return response({
|
||||
profile: profileName,
|
||||
gameToken: `gateway-token-${profileName}`,
|
||||
expiresAt: '2026-07-31T01:00:00.000Z',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
|
||||
const installGameRoute = async (profileName: 'che:903' | 'hwe:903'): Promise<void> => {
|
||||
const handle = async (route: Route): Promise<void> => {
|
||||
expect(new URL(route.request().url()).pathname).toBe(
|
||||
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
|
||||
);
|
||||
const results = [];
|
||||
for (const operation of operationNames(route)) {
|
||||
if (operation === 'auth.exchangeGatewayToken') {
|
||||
operations.get(profileName)?.push('exchangeGatewayToken');
|
||||
results.push(
|
||||
response({
|
||||
accessToken: `access-token-${profileName}`,
|
||||
profile: profileName,
|
||||
expiresAt: '2026-07-31T01:00:00.000Z',
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (operation === 'general.adjustIcon') {
|
||||
operations.get(profileName)?.push('adjustIcon');
|
||||
if (profileName === 'hwe:903') {
|
||||
hweAdjustCount += 1;
|
||||
if (options.delayHweAdjust) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
if (options.failHweAdjustOnce && hweAdjustCount === 1) {
|
||||
results.push(errorResponse(operation, 'HWE 아이콘 적용 실패'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
expect(route.request().headers().authorization).toBe(`Bearer access-token-${profileName}`);
|
||||
results.push(response({ generalId: 101, updated: true }));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unhandled ${profileName} tRPC operation: ${operation}`);
|
||||
}
|
||||
await fulfillTrpc(route, results);
|
||||
};
|
||||
await page.route(`**/${profileName.split(':')[0]}/api/trpc/**`, handle);
|
||||
};
|
||||
|
||||
await installGameRoute('che:903');
|
||||
await installGameRoute('hwe:903');
|
||||
|
||||
return {
|
||||
operations,
|
||||
deleteIconCount: () => deleteIconCount,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadIcon = async (page: Page): Promise<void> => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: 'new-icon.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
||||
};
|
||||
|
||||
test('selects every returned server and applies only checked servers in issue-exchange-adjust order', async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installFixture(page, { delayHweAdjust: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
|
||||
const modal = page.getByTestId('icon-server-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal).toContainText('완료되었습니다.');
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-stopped:903')).toHaveCount(0);
|
||||
await expect(page.locator('.icon-server-dialog')).toBeFocused();
|
||||
|
||||
await page.getByTestId('icon-server-apply').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByTestId('icon-server-close')).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(page.getByTestId('icon-server-apply')).toBeFocused();
|
||||
|
||||
await page.getByTestId('icon-server-option-che:903').uncheck();
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
await expect(page.getByTestId('icon-server-apply')).toBeDisabled();
|
||||
await expect(page.getByTestId('icon-server-close')).toBeDisabled();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId('icon-server-apply').evaluate((element) => ({
|
||||
opacity: getComputedStyle(element).opacity,
|
||||
transitionDuration: getComputedStyle(element).transitionDuration,
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
opacity: '0.65',
|
||||
transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s',
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const dialog = document.querySelector<HTMLElement>('.icon-server-dialog');
|
||||
return Boolean(dialog && dialog.contains(document.activeElement));
|
||||
})
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용 중');
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
|
||||
|
||||
expect(fixture.operations.get('che:903')).toEqual([]);
|
||||
expect(fixture.operations.get('hwe:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(modal).toBeVisible();
|
||||
await page.getByTestId('icon-server-close').click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused();
|
||||
});
|
||||
|
||||
test('keeps per-server failures visible and retries only failed servers', async ({ page }) => {
|
||||
const fixture = await installFixture(page, { failHweAdjustOnce: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
|
||||
await expect(page.getByTestId('icon-server-result-che:903')).toContainText('적용됨');
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패');
|
||||
await expect(page.getByTestId('icon-server-retry')).toBeVisible();
|
||||
|
||||
await page.getByTestId('icon-server-retry').click();
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
|
||||
await expect(page.getByTestId('icon-server-retry')).toHaveCount(0);
|
||||
|
||||
expect(fixture.operations.get('che:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']);
|
||||
expect(fixture.operations.get('hwe:903')).toEqual([
|
||||
'issueGameSession',
|
||||
'exchangeGatewayToken',
|
||||
'adjustIcon',
|
||||
'issueGameSession',
|
||||
'exchangeGatewayToken',
|
||||
'adjustIcon',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the Ref delete confirmation and opens the modal only after acceptance', async ({ page }, testInfo) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('account');
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('아이콘을 제거할까요?');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toHaveCount(0);
|
||||
expect(fixture.deleteIconCount()).toBe(0);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('아이콘을 제거할까요?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toHaveCount(0);
|
||||
expect(fixture.deleteIconCount()).toBe(1);
|
||||
await page.locator('.icon-server-dialog').screenshot({
|
||||
path: testInfo.outputPath('core-icon-modal-desktop.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const dialogStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-dialog')!);
|
||||
const titleStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-header h2')!);
|
||||
const closeStyle = getComputedStyle(document.querySelector<HTMLElement>('[data-testid="icon-server-close"]')!);
|
||||
const secondaryStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-footer .secondary')!);
|
||||
const applyStyle = getComputedStyle(document.querySelector<HTMLElement>('[data-testid="icon-server-apply"]')!);
|
||||
const backdropStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-backdrop')!);
|
||||
const motionStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-dialog')!);
|
||||
return {
|
||||
dialog: rect('.icon-server-dialog'),
|
||||
header: rect('.icon-server-header'),
|
||||
title: rect('.icon-server-header h2'),
|
||||
body: rect('.icon-server-body'),
|
||||
footer: rect('.icon-server-footer'),
|
||||
close: rect('[data-testid="icon-server-close"]'),
|
||||
secondary: rect('.icon-server-footer .secondary'),
|
||||
apply: rect('[data-testid="icon-server-apply"]'),
|
||||
dialogStyle: {
|
||||
color: dialogStyle.color,
|
||||
backgroundColor: dialogStyle.backgroundColor,
|
||||
borderColor: dialogStyle.borderColor,
|
||||
borderRadius: dialogStyle.borderRadius,
|
||||
boxShadow: dialogStyle.boxShadow,
|
||||
fontFamily: dialogStyle.fontFamily,
|
||||
fontSize: dialogStyle.fontSize,
|
||||
lineHeight: dialogStyle.lineHeight,
|
||||
},
|
||||
titleStyle: {
|
||||
fontSize: titleStyle.fontSize,
|
||||
fontWeight: titleStyle.fontWeight,
|
||||
lineHeight: titleStyle.lineHeight,
|
||||
},
|
||||
closeStyle: {
|
||||
color: closeStyle.color,
|
||||
backgroundColor: closeStyle.backgroundColor,
|
||||
borderColor: closeStyle.borderColor,
|
||||
borderRadius: closeStyle.borderRadius,
|
||||
fontSize: closeStyle.fontSize,
|
||||
fontWeight: closeStyle.fontWeight,
|
||||
lineHeight: closeStyle.lineHeight,
|
||||
padding: closeStyle.padding,
|
||||
opacity: closeStyle.opacity,
|
||||
},
|
||||
secondaryStyle: {
|
||||
color: secondaryStyle.color,
|
||||
backgroundColor: secondaryStyle.backgroundColor,
|
||||
borderColor: secondaryStyle.borderColor,
|
||||
borderRadius: secondaryStyle.borderRadius,
|
||||
fontSize: secondaryStyle.fontSize,
|
||||
fontWeight: secondaryStyle.fontWeight,
|
||||
lineHeight: secondaryStyle.lineHeight,
|
||||
padding: secondaryStyle.padding,
|
||||
},
|
||||
applyStyle: {
|
||||
color: applyStyle.color,
|
||||
backgroundColor: applyStyle.backgroundColor,
|
||||
borderColor: applyStyle.borderColor,
|
||||
borderRadius: applyStyle.borderRadius,
|
||||
fontSize: applyStyle.fontSize,
|
||||
fontWeight: applyStyle.fontWeight,
|
||||
lineHeight: applyStyle.lineHeight,
|
||||
padding: applyStyle.padding,
|
||||
transitionDuration: applyStyle.transitionDuration,
|
||||
transitionProperty: applyStyle.transitionProperty,
|
||||
},
|
||||
backdropStyle: {
|
||||
transitionDuration: backdropStyle.transitionDuration,
|
||||
transitionProperty: backdropStyle.transitionProperty,
|
||||
},
|
||||
motionStyle: {
|
||||
transitionDuration: motionStyle.transitionDuration,
|
||||
transitionProperty: motionStyle.transitionProperty,
|
||||
transitionTimingFunction: motionStyle.transitionTimingFunction,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.dialog).toEqual({ x: 470, y: 28, width: 500, height: 224 });
|
||||
expect(geometry.header).toEqual({ x: 471, y: 29, width: 498, height: 93 });
|
||||
expect(geometry.title).toEqual({ x: 487, y: 45, width: 297, height: 60 });
|
||||
expect(geometry.body).toEqual({ x: 471, y: 122, width: 498, height: 56 });
|
||||
expect(geometry.footer).toEqual({ x: 471, y: 178, width: 498, height: 73 });
|
||||
expect(geometry.close).toEqual({ x: 927, y: 60, width: 26, height: 30 });
|
||||
expect(geometry.secondary).toEqual({ x: 805, y: 195, width: 54, height: 40 });
|
||||
expect(geometry.apply).toEqual({ x: 867, y: 195, width: 86, height: 40 });
|
||||
expect(geometry.dialogStyle).toMatchObject({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(48, 48, 48)',
|
||||
borderColor: 'rgb(68, 68, 68)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'none',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
});
|
||||
expect(geometry.dialogStyle.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.titleStyle).toEqual({ fontSize: '20px', fontWeight: '500', lineHeight: '30px' });
|
||||
expect(geometry.closeStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(107, 107, 107)',
|
||||
borderColor: 'rgb(255, 255, 255)',
|
||||
borderRadius: '0px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
lineHeight: '24px',
|
||||
padding: '1px 6px',
|
||||
opacity: '1',
|
||||
});
|
||||
expect(geometry.secondaryStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(68, 68, 68)',
|
||||
borderColor: 'rgb(61, 61, 61)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '24px',
|
||||
padding: '6px 12px',
|
||||
});
|
||||
expect(geometry.applyStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(55, 90, 127)',
|
||||
borderColor: 'rgb(50, 81, 114)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '24px',
|
||||
padding: '6px 12px',
|
||||
transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s',
|
||||
transitionProperty: 'color, background-color, border-color, box-shadow',
|
||||
});
|
||||
expect(geometry.backdropStyle).toEqual({
|
||||
transitionDuration: '0.15s',
|
||||
transitionProperty: 'opacity',
|
||||
});
|
||||
expect(geometry.motionStyle).toEqual({
|
||||
transitionDuration: '0.3s',
|
||||
transitionProperty: 'transform',
|
||||
transitionTimingFunction: 'ease-out',
|
||||
});
|
||||
const apply = page.getByTestId('icon-server-apply');
|
||||
const baseBackground = await apply.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await apply.hover();
|
||||
expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground);
|
||||
await page.getByTestId('icon-server-option-hwe:903').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(apply).toBeFocused();
|
||||
expect(
|
||||
await apply.evaluate((element) => ({
|
||||
outlineStyle: getComputedStyle(element).outlineStyle,
|
||||
outlineWidth: getComputedStyle(element).outlineWidth,
|
||||
}))
|
||||
).toEqual({ outlineStyle: 'none', outlineWidth: '0px' });
|
||||
const applyBox = await apply.boundingBox();
|
||||
expect(applyBox).not.toBeNull();
|
||||
await page.mouse.move(applyBox!.x + applyBox!.width / 2, applyBox!.y + applyBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground);
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.up();
|
||||
await page.mouse.click(5, 5);
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform !== 'none')
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform))
|
||||
.toBe('none');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await installFixture(page, { failHweAdjustOnce: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패');
|
||||
|
||||
const containment = await page.evaluate(() => {
|
||||
const selectors = [
|
||||
'.icon-server-backdrop',
|
||||
'.icon-server-dialog',
|
||||
'.icon-server-header',
|
||||
'.icon-server-body',
|
||||
'.icon-server-footer',
|
||||
];
|
||||
const dialog = document.querySelector<HTMLElement>('.icon-server-dialog')!;
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
return {
|
||||
rect: {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
},
|
||||
regionsFit: selectors.every((selector) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)!;
|
||||
return element.scrollWidth <= element.clientWidth;
|
||||
}),
|
||||
focusInside: dialog.contains(document.activeElement),
|
||||
};
|
||||
});
|
||||
expect(containment.rect.left).toBeGreaterThanOrEqual(0);
|
||||
expect(containment.rect.right).toBeLessThanOrEqual(320);
|
||||
expect(containment.regionsFit).toBe(true);
|
||||
expect(containment.focusInside).toBe(true);
|
||||
|
||||
await page.getByRole('button', { name: '아이콘 변경' }).focus();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.querySelector<HTMLElement>('.icon-server-dialog')?.contains(document.activeElement)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('matches the Ref one-server modal geometry at 320px', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await installFixture(page);
|
||||
await page.goto('account');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.locator('.icon-server-dialog').screenshot({
|
||||
path: testInfo.outputPath('core-icon-modal-mobile.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const textLines = (selector: string) => {
|
||||
const root = document.querySelector(selector)!;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const lines = new Map<number, string>();
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
for (let offset = 0; offset < (node.textContent?.length ?? 0); offset += 1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + 1);
|
||||
const box = range.getBoundingClientRect();
|
||||
if (box.width > 0) {
|
||||
const top = Math.round(box.top);
|
||||
lines.set(top, `${lines.get(top) ?? ''}${node.textContent?.[offset] ?? ''}`);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return [...lines.entries()].sort(([left], [right]) => left - right).map(([, text]) => text);
|
||||
};
|
||||
return {
|
||||
dialog: rect('.icon-server-dialog'),
|
||||
header: rect('.icon-server-header'),
|
||||
title: rect('.icon-server-header h2'),
|
||||
body: rect('.icon-server-body'),
|
||||
footer: rect('.icon-server-footer'),
|
||||
close: rect('[data-testid="icon-server-close"]'),
|
||||
apply: rect('[data-testid="icon-server-apply"]'),
|
||||
titleLines: textLines('.icon-server-header h2'),
|
||||
titleFontMetrics: (() => {
|
||||
const style = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-header h2')!);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.font = style.font;
|
||||
return {
|
||||
font: context.font,
|
||||
textWidth: context.measureText('새 아이콘을 적용할 서버를 선택하세요.').width,
|
||||
selectionPrefixWidth: context.measureText('새 아이콘을 적용할 서버를 선택').width,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
dialog: { x: 8, y: 8, width: 304, height: 254 },
|
||||
header: { x: 9, y: 9, width: 302, height: 123 },
|
||||
title: { x: 25, y: 25, width: 244, height: 90 },
|
||||
body: { x: 9, y: 132, width: 302, height: 56 },
|
||||
footer: { x: 9, y: 188, width: 302, height: 73 },
|
||||
close: { x: 269, y: 55, width: 26, height: 30 },
|
||||
apply: { x: 209, y: 205, width: 86, height: 40 },
|
||||
titleLines: ['완료되었습니다.', '새 아이콘을 적용할 서버를 선택', '하세요.'],
|
||||
titleFontMetrics: {
|
||||
font: '500 20px Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||
textWidth: 297,
|
||||
selectionPrefixWidth: 241,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('reopens server synchronization without consuming the daily icon change', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.goto('account');
|
||||
await page.getByRole('button', { name: '현재 아이콘 서버 적용' }).click();
|
||||
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
});
|
||||
@@ -100,7 +100,8 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
await page.route('http://localhost:15015/api/trpc/**', async (route) => {
|
||||
await page.route('**/hwe/api/trpc/**', async (route) => {
|
||||
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
|
||||
const authorization = route.request().headers().authorization;
|
||||
const results = operationNames(route).map((operation) => {
|
||||
gameOperations.push({ operation, authorization });
|
||||
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
'lobby-admin-navigation.spec.ts',
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
'account-icon-sync.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
@@ -31,7 +32,7 @@ export default defineConfig({
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
"VITE_APP_BASE_PATH=/gateway VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
"export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15130/gateway/',
|
||||
reuseExistingServer: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>삼국지 모의전투 HiDCHe - Gateway</title>
|
||||
</head>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:operations": "playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { sealPassword } from '../utils/passwordEnvelope';
|
||||
|
||||
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
|
||||
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
|
||||
type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
|
||||
type IconSyncRow = IconSyncProfile & {
|
||||
selected: boolean;
|
||||
state: IconSyncState;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const account = ref<Account | null>(null);
|
||||
@@ -20,6 +28,15 @@ const newPasswordConfirm = ref('');
|
||||
const deletePassword = ref('');
|
||||
const iconData = ref('');
|
||||
const iconFilename = ref('');
|
||||
const iconServerModalOpen = ref(false);
|
||||
const iconServerBusy = ref(false);
|
||||
const iconServerStaticFeedback = ref(false);
|
||||
const iconServerMessage = ref('');
|
||||
const iconServerRows = ref<IconSyncRow[]>([]);
|
||||
const iconServerDialog = ref<HTMLElement | null>(null);
|
||||
let iconServerReturnFocus: HTMLElement | null = null;
|
||||
let previousBodyOverflow = '';
|
||||
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
|
||||
|
||||
@@ -29,6 +46,11 @@ const gradeLabel = computed(() => {
|
||||
return '일반회원';
|
||||
});
|
||||
|
||||
const selectedIconServerCount = computed(
|
||||
() => iconServerRows.value.filter((row) => row.selected && row.state !== 'success').length
|
||||
);
|
||||
const failedIconServerCount = computed(() => iconServerRows.value.filter((row) => row.state === 'error').length);
|
||||
|
||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
@@ -108,6 +130,148 @@ const scheduleDeletion = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const focusIconServerModal = async (preferDialog = false): Promise<void> => {
|
||||
await nextTick();
|
||||
if (preferDialog) {
|
||||
iconServerDialog.value?.focus();
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
iconServerDialog.value?.querySelector<HTMLElement>('input:not(:disabled)') ??
|
||||
iconServerDialog.value?.querySelector<HTMLElement>('button:not(:disabled)');
|
||||
(target ?? iconServerDialog.value)?.focus();
|
||||
};
|
||||
|
||||
const handleIconServerFocusIn = (event: FocusEvent): void => {
|
||||
if (!iconServerModalOpen.value || !iconServerDialog.value) return;
|
||||
if (event.target instanceof Node && iconServerDialog.value.contains(event.target)) return;
|
||||
void focusIconServerModal(iconServerBusy.value);
|
||||
};
|
||||
|
||||
const openIconServerModal = (profiles: IconSyncProfile[], returnFocus?: HTMLElement | null): void => {
|
||||
iconServerReturnFocus =
|
||||
returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
iconServerRows.value = profiles.map((profile) => ({
|
||||
...profile,
|
||||
selected: true,
|
||||
state: 'idle',
|
||||
errorMessage: '',
|
||||
}));
|
||||
iconServerMessage.value = profiles.length === 0 ? '현재 아이콘을 적용할 수 있는 실행 중인 서버가 없습니다.' : '';
|
||||
iconServerModalOpen.value = true;
|
||||
previousBodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('focusin', handleIconServerFocusIn);
|
||||
void focusIconServerModal(true);
|
||||
};
|
||||
|
||||
const closeIconServerModal = (): void => {
|
||||
if (iconServerBusy.value) return;
|
||||
iconServerModalOpen.value = false;
|
||||
document.removeEventListener('focusin', handleIconServerFocusIn);
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
const returnFocus = iconServerReturnFocus;
|
||||
iconServerReturnFocus = null;
|
||||
void nextTick(() => returnFocus?.focus());
|
||||
};
|
||||
|
||||
const showIconServerStaticFeedback = async (): Promise<void> => {
|
||||
if (iconServerStaticTimer) {
|
||||
clearTimeout(iconServerStaticTimer);
|
||||
}
|
||||
iconServerStaticFeedback.value = false;
|
||||
await nextTick();
|
||||
iconServerStaticFeedback.value = true;
|
||||
iconServerStaticTimer = setTimeout(() => {
|
||||
iconServerStaticFeedback.value = false;
|
||||
iconServerStaticTimer = null;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleIconServerModalKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
void showIconServerStaticFeedback();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !iconServerDialog.value) return;
|
||||
const focusable = Array.from(
|
||||
iconServerDialog.value.querySelectorAll<HTMLElement>('input:not(:disabled), button:not(:disabled)')
|
||||
);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
iconServerDialog.value.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (!iconServerDialog.value.contains(document.activeElement)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (
|
||||
event.shiftKey &&
|
||||
(document.activeElement === first || document.activeElement === iconServerDialog.value)
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void> => {
|
||||
row.state = 'pending';
|
||||
row.errorMessage = '';
|
||||
try {
|
||||
const issued = await trpc.auth.issueGameSession.mutate({
|
||||
sessionToken: token,
|
||||
profile: row.profileName,
|
||||
});
|
||||
const publicGameTrpc = createGameTrpc(row.profile, row.apiPort);
|
||||
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: issued.gameToken,
|
||||
});
|
||||
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
|
||||
await gameTrpc.general.adjustIcon.mutate();
|
||||
row.state = 'success';
|
||||
} catch (error) {
|
||||
row.state = 'error';
|
||||
row.errorMessage = error instanceof Error ? error.message : '서버 적용에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const applyIconToSelectedServers = async (retryFailedOnly = false): Promise<void> => {
|
||||
if (iconServerBusy.value) return;
|
||||
const targets = iconServerRows.value.filter(
|
||||
(row) => row.selected && row.state !== 'success' && (!retryFailedOnly || row.state === 'error')
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
iconServerMessage.value = retryFailedOnly ? '재시도할 실패 서버가 없습니다.' : '적용할 서버를 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
const token = sessionToken();
|
||||
if (!token) {
|
||||
iconServerMessage.value = '로그인이 필요합니다.';
|
||||
return;
|
||||
}
|
||||
iconServerBusy.value = true;
|
||||
iconServerMessage.value = '';
|
||||
await focusIconServerModal(true);
|
||||
try {
|
||||
await Promise.all(targets.map((row) => syncIconToServer(row, token)));
|
||||
const failed = targets.filter((row) => row.state === 'error').length;
|
||||
iconServerMessage.value =
|
||||
failed === 0
|
||||
? '선택한 서버에 아이콘을 적용했습니다.'
|
||||
: `${failed}개 서버에 적용하지 못했습니다. 실패한 서버만 다시 시도할 수 있습니다.`;
|
||||
} finally {
|
||||
iconServerBusy.value = false;
|
||||
await focusIconServerModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectIcon = async (event: Event): Promise<void> => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -126,7 +290,8 @@ const selectIcon = async (event: Event): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const changeIcon = async (): Promise<void> => {
|
||||
const changeIcon = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
@@ -135,23 +300,51 @@ const changeIcon = async (): Promise<void> => {
|
||||
if (account.value) account.value = { ...account.value, iconUrl: result.iconUrl };
|
||||
iconData.value = '';
|
||||
iconFilename.value = '';
|
||||
successMessage.value = '전용 아이콘을 변경했습니다.';
|
||||
successMessage.value = result.flushPublished
|
||||
? '전용 아이콘을 변경했습니다.'
|
||||
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteIcon = async (): Promise<void> => {
|
||||
const deleteIcon = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (!window.confirm('아이콘을 제거할까요?')) return;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.deleteIcon.mutate({ sessionToken: token });
|
||||
const result = await trpc.account.deleteIcon.mutate({ sessionToken: token });
|
||||
if (account.value) account.value = { ...account.value, iconUrl: null };
|
||||
successMessage.value = '전용 아이콘을 제거했습니다.';
|
||||
successMessage.value = result.flushPublished
|
||||
? '전용 아이콘을 제거했습니다.'
|
||||
: '전용 아이콘을 제거했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
const prepareIconSync = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
const result = await trpc.account.prepareIconSync.query({ sessionToken: token });
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadAccount();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('focusin', handleIconServerFocusIn);
|
||||
if (iconServerModalOpen.value) {
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
}
|
||||
if (iconServerStaticTimer) {
|
||||
clearTimeout(iconServerStaticTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,24 +458,41 @@ onMounted(() => {
|
||||
<tr>
|
||||
<th class="legacy-bg1">전용<br />아이콘</th>
|
||||
<td class="icon-preview" colspan="2">
|
||||
<img v-if="account.iconUrl" :src="account.iconUrl" width="64" height="64" alt="현재 아이콘" />
|
||||
<img
|
||||
v-if="account.iconUrl"
|
||||
:src="account.iconUrl"
|
||||
width="64"
|
||||
height="64"
|
||||
alt="현재 아이콘"
|
||||
/>
|
||||
<span v-else>기본 아이콘</span>
|
||||
<img v-if="iconData" :src="iconData" width="64" height="64" alt="새 아이콘 미리보기" />
|
||||
</td>
|
||||
<td class="icon-actions" colspan="3">
|
||||
<input class="skin-input filename" :value="iconFilename" readonly aria-label="선택한 아이콘" />
|
||||
<input
|
||||
class="skin-input filename"
|
||||
:value="iconFilename"
|
||||
readonly
|
||||
aria-label="선택한 아이콘"
|
||||
/>
|
||||
<label class="skin-button file-button">
|
||||
찾아보기
|
||||
<input
|
||||
type="file"
|
||||
accept=".avif,.webp,.jpg,.jpeg,.png,.gif"
|
||||
@change="selectIcon"
|
||||
/>
|
||||
<input type="file" accept=".avif,.webp,.jpg,.jpeg,.png,.gif" @change="selectIcon" />
|
||||
</label>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="changeIcon">
|
||||
<button
|
||||
class="skin-button half-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="changeIcon"
|
||||
>
|
||||
아이콘 변경
|
||||
</button>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="deleteIcon">
|
||||
<button
|
||||
class="skin-button half-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="deleteIcon"
|
||||
>
|
||||
아이콘 제거
|
||||
</button>
|
||||
</td>
|
||||
@@ -298,6 +508,16 @@ onMounted(() => {
|
||||
<th class="legacy-bg1">도움말</th>
|
||||
<td colspan="5" class="help-cell">
|
||||
<p>아이콘은 64 x 64픽셀 ~ 128 x 128픽셀 사이, 50KB 이하 파일만 가능합니다.</p>
|
||||
<p>
|
||||
<button
|
||||
class="skin-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="prepareIconSync"
|
||||
>
|
||||
현재 아이콘 서버 적용
|
||||
</button>
|
||||
</p>
|
||||
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -306,6 +526,109 @@ onMounted(() => {
|
||||
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
|
||||
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition name="icon-server-modal">
|
||||
<div
|
||||
v-if="iconServerModalOpen"
|
||||
class="icon-server-backdrop"
|
||||
:class="{ 'is-static': iconServerStaticFeedback }"
|
||||
data-testid="icon-server-modal"
|
||||
@click.self="showIconServerStaticFeedback"
|
||||
@keydown="handleIconServerModalKeydown"
|
||||
>
|
||||
<section
|
||||
ref="iconServerDialog"
|
||||
class="icon-server-dialog"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
:aria-busy="iconServerBusy"
|
||||
aria-modal="true"
|
||||
aria-labelledby="icon-server-title"
|
||||
>
|
||||
<header class="icon-server-header">
|
||||
<h2 id="icon-server-title">완료되었습니다.<br />새 아이콘을 적용할 서버를 선택하세요.</h2>
|
||||
<button
|
||||
class="icon-server-dismiss"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
data-testid="icon-server-close"
|
||||
:disabled="iconServerBusy"
|
||||
@click="closeIconServerModal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="icon-server-body">
|
||||
<form class="icon-server-form" @submit.prevent="applyIconToSelectedServers(false)">
|
||||
<label
|
||||
v-for="(row, index) in iconServerRows"
|
||||
:key="row.profileName"
|
||||
class="icon-server-option"
|
||||
:for="`icon-server-${index}`"
|
||||
>
|
||||
<input
|
||||
:id="`icon-server-${index}`"
|
||||
v-model="row.selected"
|
||||
type="checkbox"
|
||||
:disabled="iconServerBusy || row.state === 'success'"
|
||||
:data-testid="`icon-server-option-${row.profileName}`"
|
||||
/>
|
||||
<span>{{ row.korName }}</span>
|
||||
<span
|
||||
class="icon-server-result"
|
||||
:class="`is-${row.state}`"
|
||||
:data-testid="`icon-server-result-${row.profileName}`"
|
||||
>
|
||||
<template v-if="row.state === 'pending'">적용 중...</template>
|
||||
<template v-else-if="row.state === 'success'">적용됨</template>
|
||||
<template v-else-if="row.state === 'error'">
|
||||
실패<span v-if="row.errorMessage">: {{ row.errorMessage }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
<p
|
||||
v-if="iconServerMessage"
|
||||
class="icon-server-message"
|
||||
:class="{ 'is-error': failedIconServerCount > 0 }"
|
||||
role="status"
|
||||
>
|
||||
{{ iconServerMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<footer class="icon-server-footer">
|
||||
<button
|
||||
class="modal-button secondary"
|
||||
type="button"
|
||||
:disabled="iconServerBusy"
|
||||
@click="closeIconServerModal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
<button
|
||||
v-if="failedIconServerCount > 0"
|
||||
class="modal-button retry"
|
||||
type="button"
|
||||
data-testid="icon-server-retry"
|
||||
:disabled="iconServerBusy"
|
||||
@click="applyIconToSelectedServers(true)"
|
||||
>
|
||||
실패 서버 재시도
|
||||
</button>
|
||||
<button
|
||||
class="modal-button primary"
|
||||
type="button"
|
||||
data-testid="icon-server-apply"
|
||||
:disabled="iconServerBusy || selectedIconServerCount === 0"
|
||||
@click="applyIconToSelectedServers(false)"
|
||||
>
|
||||
{{ iconServerBusy ? '적용 중...' : '서버 적용' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
@@ -535,9 +858,225 @@ onMounted(() => {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.icon-server-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1050;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
padding: 0 16px;
|
||||
transition: opacity 0.15s linear;
|
||||
}
|
||||
|
||||
.icon-server-modal-enter-from,
|
||||
.icon-server-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon-server-dialog {
|
||||
width: 500px;
|
||||
max-width: calc(100vw - 32px);
|
||||
margin: 28px auto;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
transform: none;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.icon-server-modal-enter-from .icon-server-dialog,
|
||||
.icon-server-modal-leave-to .icon-server-dialog {
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
.icon-server-backdrop.is-static .icon-server-dialog {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.icon-server-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-server-header h2 {
|
||||
width: 297px;
|
||||
flex: 0 0 297px;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-server-dismiss {
|
||||
width: 26px;
|
||||
height: 30px;
|
||||
margin: auto 0 auto auto;
|
||||
border: 2px outset #fff;
|
||||
border-radius: 0;
|
||||
background: #6b6b6b;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-server-dismiss:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.icon-server-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-server-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.icon-server-option {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
margin-right: 7px;
|
||||
cursor: pointer;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.icon-server-option input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.icon-server-option:has(input:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-server-result {
|
||||
max-width: 260px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.icon-server-result.is-success {
|
||||
color: #9cff9c;
|
||||
}
|
||||
|
||||
.icon-server-result.is-error,
|
||||
.icon-server-message.is-error {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.icon-server-message {
|
||||
margin: 14px 0 0;
|
||||
color: #ddd;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-server-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
border: 1px solid transparent;
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s ease-in-out,
|
||||
background-color 0.15s ease-in-out,
|
||||
border-color 0.15s ease-in-out,
|
||||
box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-button.secondary {
|
||||
width: 54px;
|
||||
border-color: #3d3d3d;
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.modal-button.primary {
|
||||
width: 86px;
|
||||
border-color: #325172;
|
||||
background: #375a7f;
|
||||
}
|
||||
|
||||
.modal-button.retry {
|
||||
border-color: #a65f00;
|
||||
background: #a65f00;
|
||||
}
|
||||
|
||||
.modal-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.modal-button:focus,
|
||||
.icon-server-dismiss:focus,
|
||||
.icon-server-option input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#account-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.icon-server-backdrop {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.icon-server-dialog {
|
||||
max-width: calc(100vw - 16px);
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
.icon-server-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon-server-header h2 {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.icon-server-dismiss {
|
||||
flex: 0 0 26px;
|
||||
}
|
||||
|
||||
.icon-server-result,
|
||||
.icon-server-message {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@ type AdminUserSanctions = {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -29,7 +28,6 @@ type AdminSanctionsPatch = {
|
||||
warningCount?: number | null;
|
||||
flags?: string[] | null;
|
||||
notes?: string | null;
|
||||
profileIconResetAt?: string | null;
|
||||
serverRestrictions?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -50,6 +48,7 @@ type AdminUser = {
|
||||
oauthType: string;
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
profileIconResetAt?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -188,7 +187,10 @@ type AdminClient = {
|
||||
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||
};
|
||||
resetProfileIcon: {
|
||||
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
|
||||
mutate: (input: { userId: string }) => Promise<{
|
||||
profileIconResetAt: string;
|
||||
flushPublished: boolean;
|
||||
}>;
|
||||
};
|
||||
forceDelete: {
|
||||
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
|
||||
@@ -971,12 +973,11 @@ const resetProfileIcon = async () => {
|
||||
});
|
||||
userResult.value = {
|
||||
...userResult.value,
|
||||
sanctions: {
|
||||
...userResult.value.sanctions,
|
||||
profileIconResetAt: result.profileIconResetAt,
|
||||
},
|
||||
profileIconResetAt: result.profileIconResetAt,
|
||||
};
|
||||
profileIconStatus.value = '아이콘 초기화 요청 완료';
|
||||
profileIconStatus.value = result.flushPublished
|
||||
? '아이콘 초기화 요청 완료'
|
||||
: '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.';
|
||||
} catch (error) {
|
||||
profileIconStatus.value = '아이콘 초기화 실패';
|
||||
}
|
||||
|
||||
@@ -43,18 +43,27 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||
const encodeLegacyIconPath = (value: string): string =>
|
||||
value
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (segment === '.') return '%2E';
|
||||
if (segment === '..') return '%2E%2E';
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join('/');
|
||||
const resolveGeneralPicture = (general: LobbyGeneral): string => {
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return general.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `/image/icons/${encodeURIComponent(picture)}`;
|
||||
: `/image/icons/${encodeLegacyIconPath(picture)}`;
|
||||
};
|
||||
const handleGeneralPictureError = (event: Event): void => {
|
||||
const image = event.currentTarget as HTMLImageElement;
|
||||
if (image.dataset.fallbackApplied === 'true') {
|
||||
if (image.dataset.generalIconFallbackSource === image.currentSrc) {
|
||||
return;
|
||||
}
|
||||
image.dataset.fallbackApplied = 'true';
|
||||
image.dataset.generalIconFallbackSource = image.currentSrc;
|
||||
image.src = '/image/icons/default.jpg';
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface AccountIconProjection {
|
||||
revision: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
}
|
||||
|
||||
export const isCanonicalIsoTimestamp = (value: string): boolean => {
|
||||
const parsed = new Date(value);
|
||||
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
|
||||
};
|
||||
|
||||
export const parseAccountIconProjection = (value: unknown): AccountIconProjection => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Account icon projection must be an object.');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).length !== 3 ||
|
||||
!Object.hasOwn(record, 'revision') ||
|
||||
!Object.hasOwn(record, 'picture') ||
|
||||
!Object.hasOwn(record, 'imageServer') ||
|
||||
typeof record.revision !== 'string' ||
|
||||
!isCanonicalIsoTimestamp(record.revision) ||
|
||||
typeof record.picture !== 'string' ||
|
||||
record.picture.length === 0 ||
|
||||
typeof record.imageServer !== 'number' ||
|
||||
!Number.isSafeInteger(record.imageServer) ||
|
||||
record.imageServer < 0
|
||||
) {
|
||||
throw new Error('Account icon projection is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: record.revision,
|
||||
picture: record.picture,
|
||||
imageServer: record.imageServer,
|
||||
};
|
||||
};
|
||||
|
||||
const canonicalRevision = (value: string | undefined): string | null =>
|
||||
value && isCanonicalIsoTimestamp(value) ? value : null;
|
||||
|
||||
export const resolveAccountIconProjection = (input: {
|
||||
createdAt: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
}): AccountIconProjection => {
|
||||
const iconRevision =
|
||||
canonicalRevision(input.iconRevision) ??
|
||||
canonicalRevision(input.iconUpdatedAt) ??
|
||||
canonicalRevision(input.createdAt);
|
||||
if (!iconRevision) {
|
||||
throw new Error('User account icon revision is invalid.');
|
||||
}
|
||||
const resetRevision = canonicalRevision(input.profileIconResetAt);
|
||||
if (resetRevision && resetRevision >= iconRevision) {
|
||||
return {
|
||||
revision: resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
if (!input.picture || !Number.isSafeInteger(input.imageServer) || input.imageServer < 0) {
|
||||
throw new Error('User account icon is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: iconRevision,
|
||||
picture: input.picture,
|
||||
imageServer: input.imageServer,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import { isCanonicalIsoTimestamp } from './accountIconProjection.js';
|
||||
|
||||
export interface UserSanctions {
|
||||
bannedUntil?: string;
|
||||
mutedUntil?: string;
|
||||
@@ -7,7 +9,6 @@ export interface UserSanctions {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
@@ -26,6 +27,8 @@ export interface GatewayUserInfo {
|
||||
roles: string[];
|
||||
picture?: string;
|
||||
imageServer?: number;
|
||||
iconUpdatedAt?: string;
|
||||
profileIconResetAt?: string;
|
||||
canUseGeneralPicture?: boolean;
|
||||
createdAt?: string;
|
||||
legacyMemberNo?: number;
|
||||
@@ -90,6 +93,10 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.picture !== undefined && typeof user.picture !== 'string') ||
|
||||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
|
||||
(user.iconUpdatedAt !== undefined &&
|
||||
(typeof user.iconUpdatedAt !== 'string' || !isCanonicalIsoTimestamp(user.iconUpdatedAt))) ||
|
||||
(user.profileIconResetAt !== undefined &&
|
||||
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
|
||||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
|
||||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './turnDaemon/types.js';
|
||||
export * from './realtime/keys.js';
|
||||
export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
|
||||
@@ -205,6 +205,14 @@ export type TurnDaemonCommand =
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconRevision: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
requestId?: string;
|
||||
@@ -220,6 +228,7 @@ export type TurnDaemonCommand =
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
@@ -516,6 +525,18 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: false;
|
||||
code: 'CONFLICT' | 'PRECONDITION_FAILED';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
ok: true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAccountIconProjection } from '../src/auth/accountIconProjection.js';
|
||||
|
||||
describe('account icon projection', () => {
|
||||
it('resolves the current account icon from its durable revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an administrator reset win at the same or a later revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
profileIconResetAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ createdAt: 'not-a-date', picture: 'account.png', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: '', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: 'account.png', imageServer: -1 },
|
||||
])('rejects invalid durable account icon state: %j', (value) => {
|
||||
expect(() => resolveAccountIconProjection(value)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decryptGameSessionToken,
|
||||
encryptGameSessionToken,
|
||||
parseGameSessionTokenPayload,
|
||||
type GameSessionTokenPayload,
|
||||
} from '../src/auth/gameToken.js';
|
||||
|
||||
const buildPayload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-31T09:00:00.000Z',
|
||||
expiresAt: '2026-07-31T09:10:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '테스트',
|
||||
roles: ['user'],
|
||||
picture: 'account-icon.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: '2026-07-31T08:59:00.000Z',
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
describe('game session token account icon revision', () => {
|
||||
it('round-trips the canonical account icon revision', () => {
|
||||
const payload = buildPayload();
|
||||
const token = encryptGameSessionToken(payload, 'test-only-secret');
|
||||
|
||||
expect(decryptGameSessionToken(token, 'test-only-secret')).toEqual(payload);
|
||||
});
|
||||
|
||||
it.each([1, {}, '2026-07-31', '2026-07-31T08:59:00Z', 'not-a-date'])(
|
||||
'rejects a non-canonical icon revision: %j',
|
||||
(iconUpdatedAt) => {
|
||||
const payload = buildPayload() as unknown as {
|
||||
user: { iconUpdatedAt: unknown };
|
||||
};
|
||||
payload.user.iconUpdatedAt = iconUpdatedAt;
|
||||
|
||||
expect(parseGameSessionTokenPayload(payload)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
|
||||
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
|
||||
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE "app_user"
|
||||
ADD COLUMN "icon_revision" TIMESTAMP(3),
|
||||
ADD COLUMN "profile_icon_reset_at" TIMESTAMP(3);
|
||||
|
||||
-- Before this migration the administrator reset marker lived in the sanctions
|
||||
-- JSON. Refuse malformed non-string data instead of silently losing a reset;
|
||||
-- PostgreSQL's cast below likewise makes an invalid datetime fail the deploy.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "app_user"
|
||||
WHERE "sanctions" ? 'profileIconResetAt'
|
||||
AND (
|
||||
jsonb_typeof("sanctions" -> 'profileIconResetAt') IS DISTINCT FROM 'string'
|
||||
OR NOT ("sanctions" ->> 'profileIconResetAt') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'app_user.sanctions.profileIconResetAt must be an ISO datetime string';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "profile_icon_reset_at" =
|
||||
(("sanctions" ->> 'profileIconResetAt')::TIMESTAMPTZ AT TIME ZONE 'UTC')
|
||||
WHERE "sanctions" ? 'profileIconResetAt';
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "icon_revision" = GREATEST(
|
||||
"created_at",
|
||||
COALESCE("icon_updated_at", "created_at"),
|
||||
COALESCE("profile_icon_reset_at", "created_at")
|
||||
);
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "sanctions" = "sanctions" - 'profileIconResetAt'
|
||||
WHERE "sanctions" ? 'profileIconResetAt';
|
||||
|
||||
COMMIT;
|
||||
@@ -73,6 +73,8 @@ model AppUser {
|
||||
picture String @default("default.jpg")
|
||||
imageServer Int @default(0) @map("image_server")
|
||||
iconUpdatedAt DateTime? @map("icon_updated_at")
|
||||
iconRevision DateTime? @map("icon_revision")
|
||||
profileIconResetAt DateTime? @map("profile_icon_reset_at")
|
||||
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
||||
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
||||
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${GATEWAY_MIGRATION_TEST_DATABASE_URL:?GATEWAY_MIGRATION_TEST_DATABASE_URL is required}"
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
package_dir=$(dirname "$script_dir")
|
||||
prisma_dir="$package_dir/prisma"
|
||||
target_migration=20260731001000_add_account_icon_revision
|
||||
run_id=$(date -u +%m%d%H%M%S)_$$
|
||||
predecessor_schema="gateway_icon_predecessor_$run_id"
|
||||
fresh_schema="gateway_icon_fresh_$run_id"
|
||||
ownership_token="sammo-gateway-icon-migration:$run_id"
|
||||
work_dir=$(mktemp -d "$package_dir/.gateway-icon-migration.XXXXXX")
|
||||
|
||||
cleanup() {
|
||||
cleanup_status=0
|
||||
OWNERSHIP_TOKEN=$ownership_token \
|
||||
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
|
||||
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
|
||||
const ownership = await client.query(
|
||||
"SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1",
|
||||
[schema]
|
||||
);
|
||||
if (ownership.rowCount === 0) continue;
|
||||
if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) {
|
||||
throw new Error(`refusing to drop unowned schema: ${schema}`);
|
||||
}
|
||||
await client.query(`DROP SCHEMA ${quoteIdentifier(schema)} CASCADE`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
' >/dev/null 2>&1 || cleanup_status=1
|
||||
case "$work_dir" in
|
||||
"$package_dir"/.gateway-icon-migration.*)
|
||||
rm -r -- "$work_dir" || cleanup_status=1
|
||||
;;
|
||||
*)
|
||||
echo "refusing to remove unsafe migration work directory: $work_dir" >&2
|
||||
cleanup_status=1
|
||||
;;
|
||||
esac
|
||||
return "$cleanup_status"
|
||||
}
|
||||
|
||||
handle_exit() {
|
||||
exit_status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! cleanup && [ "$exit_status" -eq 0 ]; then
|
||||
exit_status=1
|
||||
fi
|
||||
exit "$exit_status"
|
||||
}
|
||||
|
||||
trap handle_exit EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
[ -d "$prisma_dir/gateway-migrations/$target_migration" ] || {
|
||||
echo "target migration is missing: $target_migration" >&2
|
||||
exit 66
|
||||
}
|
||||
|
||||
build_database_url() {
|
||||
SCHEMA_NAME=$1 DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
url.searchParams.set("schema", process.env.SCHEMA_NAME);
|
||||
process.stdout.write(url.href);
|
||||
'
|
||||
}
|
||||
|
||||
predecessor_url=$(build_database_url "$predecessor_schema")
|
||||
fresh_url=$(build_database_url "$fresh_schema")
|
||||
|
||||
OWNERSHIP_TOKEN=$ownership_token \
|
||||
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
|
||||
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
const apostrophe = String.fromCharCode(39);
|
||||
const quoteLiteral = (value) =>
|
||||
`${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`;
|
||||
await client.connect();
|
||||
try {
|
||||
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
|
||||
await client.query(`CREATE SCHEMA ${quoteIdentifier(schema)}`);
|
||||
await client.query(
|
||||
`COMMENT ON SCHEMA ${quoteIdentifier(schema)} IS ${quoteLiteral(process.env.OWNERSHIP_TOKEN)}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
stage_dir="$work_dir/stage"
|
||||
mkdir -p "$stage_dir/gateway-migrations"
|
||||
cp "$prisma_dir/gateway.prisma" "$stage_dir/gateway.prisma"
|
||||
cat >"$stage_dir/prisma.config.ts" <<'EOF'
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './gateway.prisma',
|
||||
migrations: { path: './gateway-migrations' },
|
||||
datasource: { url: process.env.GATEWAY_DATABASE_URL! },
|
||||
});
|
||||
EOF
|
||||
|
||||
found_target=0
|
||||
for migration_dir in "$prisma_dir"/gateway-migrations/[0-9]*; do
|
||||
migration_name=$(basename "$migration_dir")
|
||||
if [ "$migration_name" = "$target_migration" ]; then
|
||||
found_target=1
|
||||
break
|
||||
fi
|
||||
cp -R "$migration_dir" "$stage_dir/gateway-migrations/$migration_name"
|
||||
done
|
||||
[ "$found_target" -eq 1 ] || {
|
||||
echo "target migration was not found in migration order" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "$package_dir"
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$stage_dir/gateway.prisma" --config "$stage_dir/prisma.config.ts" \
|
||||
>"$work_dir/predecessor-deploy.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
await client.query(`
|
||||
INSERT INTO app_user (
|
||||
id, login_id, display_name, password_hash, password_salt,
|
||||
sanctions, icon_updated_at, created_at, updated_at
|
||||
) VALUES
|
||||
($1, $1, $1, $1, $1, $2::jsonb, $3, $4, $4),
|
||||
($5, $5, $5, $5, $5, $6::jsonb, $7, $4, $4),
|
||||
($8, $8, $8, $8, $8, $9::jsonb, NULL, $4, $4)
|
||||
`, [
|
||||
"legacy-reset",
|
||||
JSON.stringify({ profileIconResetAt: "2026-07-25T09:00:00.123Z", notes: "preserve" }),
|
||||
"2026-07-20T00:00:00.000Z",
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
"ordinary-icon",
|
||||
JSON.stringify({ notes: "preserve" }),
|
||||
"2026-07-20T00:00:00.000Z",
|
||||
"malformed-reset",
|
||||
JSON.stringify({ profileIconResetAt: 1234, notes: "preserve" }),
|
||||
]);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
failure_log="$work_dir/malformed-deploy.log"
|
||||
if GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$failure_log" 2>&1; then
|
||||
echo "account icon migration unexpectedly accepted a malformed reset marker" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema TARGET_MIGRATION=$target_migration DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
const columns = await client.query(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = $$app_user$$
|
||||
AND column_name IN ($$icon_revision$$, $$profile_icon_reset_at$$)
|
||||
`, [process.env.SCHEMA_NAME]);
|
||||
if (columns.rows[0].count !== 0) throw new Error("transactional DDL survived failed migration");
|
||||
const history = await client.query(`
|
||||
SELECT count(*)::int AS count,
|
||||
bool_and(finished_at IS NULL) AS unfinished,
|
||||
sum(applied_steps_count)::int AS steps
|
||||
FROM _prisma_migrations
|
||||
WHERE migration_name = $1
|
||||
`, [process.env.TARGET_MIGRATION]);
|
||||
const row = history.rows[0];
|
||||
if (row.count !== 1 || row.unfinished !== true || row.steps !== 0) {
|
||||
throw new Error(`unexpected failed migration history: ${JSON.stringify(row)}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate resolve --rolled-back "$target_migration" \
|
||||
--schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/resolve.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
await client.query(`
|
||||
UPDATE app_user
|
||||
SET sanctions = jsonb_set(sanctions, ARRAY[$$profileIconResetAt$$], to_jsonb($1::text))
|
||||
WHERE id = $$malformed-reset$$
|
||||
`, ["2026-07-26T10:00:00.456Z"]);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/recovered-deploy.log"
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/noop-deploy.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
const result = await client.query(`
|
||||
SELECT id,
|
||||
to_char(icon_revision, $$YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"$$) AS revision,
|
||||
CASE WHEN profile_icon_reset_at IS NULL THEN NULL
|
||||
ELSE to_char(profile_icon_reset_at, $$YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"$$)
|
||||
END AS reset,
|
||||
sanctions
|
||||
FROM app_user
|
||||
ORDER BY id
|
||||
`);
|
||||
const expected = [
|
||||
{ id: "legacy-reset", revision: "2026-07-25T09:00:00.123Z", reset: "2026-07-25T09:00:00.123Z", sanctions: { notes: "preserve" } },
|
||||
{ id: "malformed-reset", revision: "2026-07-26T10:00:00.456Z", reset: "2026-07-26T10:00:00.456Z", sanctions: { notes: "preserve" } },
|
||||
{ id: "ordinary-icon", revision: "2026-07-20T00:00:00.000Z", reset: null, sanctions: { notes: "preserve" } },
|
||||
];
|
||||
if (JSON.stringify(result.rows) !== JSON.stringify(expected)) {
|
||||
throw new Error(`unexpected account icon backfill: ${JSON.stringify(result.rows)}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$fresh_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/fresh-deploy.log"
|
||||
GATEWAY_DATABASE_URL=$fresh_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/fresh-noop-deploy.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/fresh-noop-deploy.log"
|
||||
|
||||
echo "Gateway account icon migration backfill, rollback, recovery, and fresh deploy passed"
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const targetUrl = process.env.REF_ACCOUNT_URL;
|
||||
const passwordFile = process.env.REF_ACCOUNT_PASSWORD_FILE;
|
||||
const username = process.env.REF_ACCOUNT_USERNAME ?? 'refuser1';
|
||||
const outputPath = process.env.REF_ACCOUNT_MODAL_OUTPUT;
|
||||
const screenshotPath = process.env.REF_ACCOUNT_MODAL_SCREENSHOT;
|
||||
const viewportWidth = Number.parseInt(process.env.REF_VIEWPORT_WIDTH ?? '1440', 10);
|
||||
const viewportHeight = Number.parseInt(process.env.REF_VIEWPORT_HEIGHT ?? '900', 10);
|
||||
|
||||
if (!targetUrl || !passwordFile || process.env.REF_ALLOW_ICON_DELETE !== '1') {
|
||||
throw new Error(
|
||||
'REF_ACCOUNT_URL, REF_ACCOUNT_PASSWORD_FILE, and REF_ALLOW_ICON_DELETE=1 are required for the disposable Ref account.'
|
||||
);
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewportWidth, height: viewportHeight },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', targetUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await loginResponse.json();
|
||||
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||
throw new Error('Ref disposable account login failed.');
|
||||
}
|
||||
|
||||
await page.goto(new URL('i_entrance/user_info.php', targetUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
await page.locator('#slot_nickname').waitFor({ state: 'visible' });
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#btn_remove_icon').click();
|
||||
const modal = page.locator('#chooseServer');
|
||||
await modal.waitFor({ state: 'visible' });
|
||||
await page.locator('#chooseServerForm input').first().waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const rect = (selector) => {
|
||||
const value = document.querySelector(selector).getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const style = (selector) => {
|
||||
const value = getComputedStyle(document.querySelector(selector));
|
||||
return {
|
||||
color: value.color,
|
||||
backgroundColor: value.backgroundColor,
|
||||
borderColor: value.borderColor,
|
||||
borderRadius: value.borderRadius,
|
||||
boxShadow: value.boxShadow,
|
||||
fontFamily: value.fontFamily,
|
||||
fontSize: value.fontSize,
|
||||
fontWeight: value.fontWeight,
|
||||
lineHeight: value.lineHeight,
|
||||
letterSpacing: value.letterSpacing,
|
||||
wordSpacing: value.wordSpacing,
|
||||
whiteSpace: value.whiteSpace,
|
||||
padding: value.padding,
|
||||
outline: value.outline,
|
||||
opacity: value.opacity,
|
||||
transitionDuration: value.transitionDuration,
|
||||
transitionProperty: value.transitionProperty,
|
||||
transitionTimingFunction: value.transitionTimingFunction,
|
||||
transform: value.transform,
|
||||
};
|
||||
};
|
||||
const textLines = (selector) => {
|
||||
const root = document.querySelector(selector);
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const lines = new Map();
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
for (let offset = 0; offset < node.textContent.length; offset += 1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + 1);
|
||||
const box = range.getBoundingClientRect();
|
||||
if (box.width > 0) {
|
||||
const top = Math.round(box.top);
|
||||
lines.set(top, `${lines.get(top) ?? ''}${node.textContent[offset]}`);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return [...lines.entries()].sort(([left], [right]) => left - right).map(([, text]) => text);
|
||||
};
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight, deviceScaleFactor: devicePixelRatio },
|
||||
dialog: rect('#chooseServer .modal-dialog'),
|
||||
content: rect('#chooseServer .modal-content'),
|
||||
header: rect('#chooseServer .modal-header'),
|
||||
title: rect('#chooseServer .modal-title'),
|
||||
titleLines: textLines('#chooseServer .modal-title'),
|
||||
body: rect('#chooseServer .modal-body'),
|
||||
footer: rect('#chooseServer .modal-footer'),
|
||||
close: rect('#chooseServer .close'),
|
||||
secondary: rect('#chooseServer .btn-secondary'),
|
||||
apply: rect('#modal-apply'),
|
||||
dialogStyle: style('#chooseServer .modal-content'),
|
||||
motionStyle: style('#chooseServer .modal-dialog'),
|
||||
titleStyle: style('#chooseServer .modal-title'),
|
||||
closeStyle: style('#chooseServer .close'),
|
||||
secondaryStyle: style('#chooseServer .btn-secondary'),
|
||||
applyStyle: style('#modal-apply'),
|
||||
modalStyle: style('#chooseServer'),
|
||||
backdropStyle: style('.modal-backdrop'),
|
||||
checked: Array.from(document.querySelectorAll('#chooseServerForm input')).map((input) => input.checked),
|
||||
labels: Array.from(document.querySelectorAll('#chooseServerForm label')).map((label) =>
|
||||
label.textContent?.trim()
|
||||
),
|
||||
activeElement: document.activeElement?.id || document.activeElement?.tagName,
|
||||
titleFontMetrics: (() => {
|
||||
const title = document.querySelector('#chooseServer .modal-title');
|
||||
const titleStyle = getComputedStyle(title);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
context.font = titleStyle.font;
|
||||
return {
|
||||
font: context.font,
|
||||
textWidth: context.measureText('새 아이콘을 적용할 서버를 선택하세요.').width,
|
||||
selectionPrefixWidth: context.measureText('새 아이콘을 적용할 서버를 선택').width,
|
||||
pretendardLoaded: document.fonts.check('20px Pretendard'),
|
||||
};
|
||||
})(),
|
||||
overflow: {
|
||||
documentFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
dialogFits:
|
||||
document.querySelector('#chooseServer .modal-dialog').scrollWidth <=
|
||||
document.querySelector('#chooseServer .modal-dialog').clientWidth,
|
||||
headerFits:
|
||||
document.querySelector('#chooseServer .modal-header').scrollWidth <=
|
||||
document.querySelector('#chooseServer .modal-header').clientWidth,
|
||||
},
|
||||
};
|
||||
});
|
||||
const measured = await measure();
|
||||
|
||||
if (screenshotPath) {
|
||||
await page.locator('#chooseServer .modal-content').screenshot({
|
||||
path: screenshotPath,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const apply = page.locator('#modal-apply');
|
||||
await apply.hover();
|
||||
measured.applyHoverStyle = (await measure()).applyStyle;
|
||||
await apply.focus();
|
||||
measured.applyFocusStyle = (await measure()).applyStyle;
|
||||
const applyBox = await apply.boundingBox();
|
||||
if (!applyBox) {
|
||||
throw new Error('Ref apply button has no rendered box.');
|
||||
}
|
||||
await page.mouse.move(applyBox.x + applyBox.width / 2, applyBox.y + applyBox.height / 2);
|
||||
await page.mouse.down();
|
||||
measured.applyActiveStyle = (await measure()).applyStyle;
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.up();
|
||||
await page.locator('#chooseServerForm input').first().focus();
|
||||
measured.checkboxFocusStyle = await page
|
||||
.locator('#chooseServerForm input')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const value = getComputedStyle(element);
|
||||
return { outline: value.outline, opacity: value.opacity };
|
||||
});
|
||||
await page.keyboard.press('Tab');
|
||||
measured.keyboardFocus = {
|
||||
activeElement: await page.evaluate(() => document.activeElement?.id || document.activeElement?.tagName),
|
||||
applyStyle: (await measure()).applyStyle,
|
||||
};
|
||||
|
||||
await page.mouse.click(5, 5);
|
||||
measured.backdropClickKeepsOpen = await modal.isVisible();
|
||||
await page.waitForTimeout(30);
|
||||
measured.staticBackdropMotionStyle = (await measure()).motionStyle;
|
||||
await page.waitForTimeout(350);
|
||||
measured.staticBackdropSettledMotionStyle = (await measure()).motionStyle;
|
||||
await page.keyboard.press('Escape');
|
||||
measured.escapeKeepsOpen = await modal.isVisible();
|
||||
|
||||
const output = `${JSON.stringify(measured, null, 2)}\n`;
|
||||
if (outputPath) {
|
||||
await writeFile(outputPath, output);
|
||||
} else {
|
||||
process.stdout.write(output);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
@@ -268,6 +268,7 @@ validate_marker_registry() {
|
||||
cd "$workspace_root"
|
||||
rg -o --no-filename \
|
||||
'process\.env\.[A-Z0-9_]+_DATABASE_URL' \
|
||||
app/gateway-api/test \
|
||||
app/game-api/test \
|
||||
app/game-engine/test \
|
||||
tools/integration-tests/test \
|
||||
@@ -453,6 +454,9 @@ pnpm --filter @sammo-ts/infra prisma:generate
|
||||
pnpm --filter @sammo-ts/common build
|
||||
pnpm --filter @sammo-ts/infra build
|
||||
|
||||
GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||
pnpm --filter @sammo-ts/infra verify:migration:account-icon
|
||||
|
||||
cleanup_resources_started=1
|
||||
create_owned_schema "$integration_schema"
|
||||
create_owned_schema "$npc_possession_schema"
|
||||
@@ -509,6 +513,9 @@ immediate_action_database_url=$(build_database_url "$immediate_action_schema")
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||
)
|
||||
export IMMEDIATE_ACTION_DATABASE_URL=$immediate_action_database_url
|
||||
run_marked_tests app/game-api \
|
||||
"$(markers_for_mode immediate_action)" \
|
||||
"immediate_action_api_postgresql"
|
||||
run_marked_tests app/game-engine \
|
||||
"$(markers_for_mode immediate_action)" \
|
||||
"immediate_action_postgresql"
|
||||
@@ -522,6 +529,10 @@ gateway_runtime_database_url=$(build_database_url "$gateway_runtime_schema")
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:gateway
|
||||
)
|
||||
export GATEWAY_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url
|
||||
export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema
|
||||
run_marked_tests app/gateway-api \
|
||||
"$(markers_for_mode gateway_runtime)" \
|
||||
"gateway_runtime_gateway_api_postgresql"
|
||||
run_marked_tests app/game-engine \
|
||||
"$(markers_for_mode gateway_runtime)" \
|
||||
"gateway_runtime_postgresql"
|
||||
|
||||
Reference in New Issue
Block a user