feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+11 -12
View File
@@ -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({
+18 -6
View File
@@ -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({
+9 -2
View File
@@ -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,