feat: complete user-facing message and account APIs

This commit is contained in:
2026-07-25 11:39:47 +00:00
parent 579d0a8d4c
commit fbfaf8df81
26 changed files with 1272 additions and 525 deletions
+2
View File
@@ -26,6 +26,8 @@ GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
GATEWAY_USER_ICON_DIR=uploads/user-icons
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons
SESSION_TTL_SECONDS=604800
GAME_SESSION_TTL_SECONDS=21600
OAUTH_SESSION_TTL_SECONDS=600
+1
View File
@@ -164,3 +164,4 @@ docker-compose.override.yml
docs/image-storage.md
playwright-report/
test-results/
uploads/
+35
View File
@@ -23,6 +23,14 @@ interface MessageRow {
message: unknown;
}
export interface StoredMessage {
id: number;
mailbox: number;
msgType: MessageType;
time: Date;
payload: MessagePayload;
}
const parsePayload = (value: unknown): MessagePayload => {
if (typeof value === 'string') {
return JSON.parse(value) as MessagePayload;
@@ -113,3 +121,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
return rows.map(toMessageView);
};
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id} AND valid_until > NOW()
LIMIT 1
`;
const row = rows[0];
if (!row) return null;
return {
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
payload: parsePayload(row.message),
};
};
export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: { validUntil: new Date() },
});
};
+145 -32
View File
@@ -14,11 +14,14 @@ import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '..
import {
fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox,
fetchMessageById,
invalidateMessages,
insertMessage,
type MessageView,
} from '../../messages/store.js';
import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -42,36 +45,39 @@ export const messagesRouter = router({
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
]);
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
[
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
]
);
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
@@ -117,11 +123,118 @@ export const messagesRouter = router({
nationId: nationId,
generalName: general.name,
latestRead: {
diplomacy: 0,
private: 0,
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({
select: { id: true, name: true, color: true, meta: true },
orderBy: { id: 'asc' },
}),
ctx.db.general.findMany({
where: { npcState: { lt: 2 } },
select: {
id: true,
name: true,
nationId: true,
officerLevel: true,
npcState: true,
meta: true,
penalty: true,
},
orderBy: { id: 'asc' },
}),
]);
const nationMeta = new Map(nations.map((nation) => [nation.id, nation.meta]));
const grouped = new Map<number, Array<[number, string, number]>>();
for (const general of generals) {
let flags = 0;
if (general.officerLevel === 12) flags |= 1;
if (general.npcState === 1) flags |= 2;
if (resolveNationPermission(general, nationMeta.get(general.nationId) ?? {}, false) === 4) flags |= 4;
const list = grouped.get(general.nationId) ?? [];
list.push([general.id, general.name, flags]);
grouped.set(general.nationId, list);
}
const nationList = [
{ id: 0, name: '재야', color: '#000000', meta: {} },
...nations.filter((nation) => nation.id !== 0),
];
return {
nation: nationList.map((nation) => ({
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name,
color: nation.color,
general: grouped.get(nation.id) ?? [],
})),
};
}),
readLatest: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: z.enum(['private', 'diplomacy']),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const privateValue = input.type === 'private' ? input.messageId : 0;
const diplomacyValue = input.type === 'diplomacy' ? input.messageId : 0;
await ctx.db.$executeRaw`
INSERT INTO message_read_state (
general_id,
latest_private_message,
latest_diplomacy_message,
updated_at
)
VALUES (${general.id}, ${privateValue}, ${diplomacyValue}, NOW())
ON CONFLICT (general_id) DO UPDATE SET
latest_private_message = GREATEST(
message_read_state.latest_private_message,
EXCLUDED.latest_private_message
),
latest_diplomacy_message = GREATEST(
message_read_state.latest_diplomacy_message,
EXCLUDED.latest_diplomacy_message
),
updated_at = NOW()
`;
return { ok: true };
}),
delete: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const message = await fetchMessageById(ctx.db, input.messageId);
if (!message) {
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
}
if (message.payload.src.generalId !== general.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
}
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
await invalidateMessages(ctx.db, ids);
return { ok: true, deletedIds: ids };
}),
getOld: authedProcedure
.input(
z.object({
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { appRouter } from '../src/router.js';
import type { GameApiContext, GeneralRow } from '../src/context.js';
const general = {
id: 7,
userId: 'user-7',
name: '보낸이',
nationId: 1,
officerLevel: 5,
npcState: 0,
meta: {},
penalty: {},
} as GeneralRow;
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2027-01-01T00:00:00.000Z',
sessionId: 'session-7',
user: {
id: 'user-7',
username: 'tester',
displayName: 'Tester',
roles: ['user'],
},
sanctions: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
general: {
findUnique: vi.fn(async () => general),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => null),
},
messageReadState: {
findUnique: vi.fn(async () => ({
generalId: general.id,
latestPrivateMessage: 11,
latestDiplomacyMessage: 13,
updatedAt: new Date(),
})),
},
message: { updateMany },
$queryRaw: vi.fn(async () => []),
$executeRaw: executeRaw,
...overrides,
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
} as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
};
describe('messages router missing-flow compatibility', () => {
it('returns persisted latest-read positions with recent messages', async () => {
const { caller } = buildContext();
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 });
});
it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext();
await caller.messages.readLatest({
generalId: general.id,
type: 'private',
messageId: 17,
});
expect(executeRaw).toHaveBeenCalledOnce();
});
it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [21, 22] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [
{
id: 23,
mailbox: general.id,
type: 'private',
src: 99,
dest: general.id,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: { generalId: 99, generalName: '타인', nationId: 1, nationName: '위', color: '#fff', icon: '' },
dest: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
text: '타인 메시지',
option: {},
},
},
]);
const { caller } = buildContext({ $queryRaw: queryRaw });
await expect(caller.messages.delete({ generalId: general.id, messageId: 23 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
@@ -492,6 +492,7 @@ export const createDatabaseTurnHooks = async (
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
level: nation.level,
+1 -1
View File
@@ -257,7 +257,7 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
chiefGeneralId: null,
chiefGeneralId: row.chiefGeneralId,
gold: row.gold,
rice: row.rice,
power: 0,
+2
View File
@@ -27,6 +27,7 @@
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^7.2.0",
"@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*",
@@ -38,6 +39,7 @@
"fastify": "^5.6.2",
"pm2": "^5.4.3",
"redis": "^5.10.0",
"sharp": "^0.34.4",
"zod": "^4.3.5"
}
}
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import sharp from 'sharp';
import { z } from 'zod';
import type { GatewayApiContext } from '../context.js';
import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
const zSessionToken = z.string().min(1);
const zPassword = z.string().min(6).max(128);
const MAX_ICON_BYTES = 50 * 1024;
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
const session = await ctx.sessions.getSession(sessionToken);
if (!session) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' });
}
const user = await ctx.users.findById(session.userId);
if (!user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' });
}
return user;
};
const decodeImage = (input: string): Buffer => {
const match = input.match(/^data:[^;]+;base64,(.+)$/);
const encoded = match?.[1] ?? input;
const buffer = Buffer.from(encoded, 'base64');
if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 50KB 이하여야 합니다.' });
}
return buffer;
};
const sameUtcDate = (left: Date, right: Date): boolean =>
left.getUTCFullYear() === right.getUTCFullYear() &&
left.getUTCMonth() === right.getUTCMonth() &&
left.getUTCDate() === right.getUTCDate();
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
}
};
const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
for (const value of dates) {
if (value && new Date(value) > now) return true;
}
return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) =>
Boolean(restriction.until && new Date(restriction.until) > now)
);
};
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)}`;
};
export const accountRouter = router({
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles: user.roles,
oauthType: user.oauthType,
createdAt: user.createdAt,
iconUrl: buildIconUrl(ctx, user),
thirdPartyUse: user.thirdPartyUse,
deleteAfter: user.deleteAfter ?? null,
};
}),
changePassword: procedure
.input(
z.object({
sessionToken: zSessionToken,
currentPassword: zPassword,
newPassword: zPassword,
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
await ctx.users.updatePassword(user.id, input.newPassword);
await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed');
return { ok: true };
}),
scheduleDeletion: procedure
.input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
if (user.deleteAfter) {
throw new TRPCError({ code: 'CONFLICT', message: '이미 탈퇴 처리되어 있습니다.' });
}
const now = new Date();
if (hasActiveSanction(user.sanctions, now)) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '징계가 남아 있어 탈퇴할 수 없습니다.' });
}
const deleteAfter = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
await ctx.users.scheduleDeletion(user.id, deleteAfter);
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-scheduled');
return { ok: true, deleteAfter: deleteAfter.toISOString() };
}),
disallowThirdPartyUse: procedure
.input(z.object({ sessionToken: zSessionToken }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
await ctx.users.setThirdPartyUse(user.id, false);
return { ok: true };
}),
changeIcon: procedure
.input(
z.object({
sessionToken: zSessionToken,
imageData: z.string().min(1).max(100_000),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
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);
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
};
}),
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 };
}),
});
@@ -43,6 +43,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
oauthId: input.oauth?.id,
email: input.oauth?.email,
oauthInfo: input.oauth?.info,
picture: 'default.jpg',
imageServer: 0,
thirdPartyUse: true,
passwordSalt: salt,
passwordHash: hasher.hash(input.password, salt),
createdAt: new Date().toISOString(),
@@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.picture = picture;
user.imageServer = imageServer;
user.iconUpdatedAt = updatedAt.toISOString();
return;
}
}
throw new Error('User not found.');
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.thirdPartyUse = allowed;
return;
}
}
throw new Error('User not found.');
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.deleteAfter = deleteAfter.toISOString();
return;
}
}
throw new Error('User not found.');
},
async deleteUser(userId: string): Promise<void> {
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
@@ -29,6 +29,11 @@ const mapUser = (row: {
oauthId: string | null;
email: string | null;
oauthInfo: GatewayPrisma.JsonValue;
picture: string;
imageServer: number;
iconUpdatedAt: Date | null;
thirdPartyUse: boolean;
deleteAfter: Date | null;
createdAt: Date;
}): UserRecord => ({
id: row.id,
@@ -40,6 +45,11 @@ const mapUser = (row: {
oauthId: row.oauthId ?? undefined,
email: row.email ?? undefined,
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
picture: row.picture,
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
createdAt: row.createdAt.toISOString(),
@@ -139,6 +149,28 @@ export const createPostgresUserRepository = (
},
});
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: {
picture,
imageServer,
iconUpdatedAt: updatedAt,
},
});
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { thirdPartyUse: allowed },
});
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { deleteAfter },
});
},
async deleteUser(userId: string): Promise<void> {
await prisma.appUser.delete({
where: { id: userId },
@@ -8,6 +8,11 @@ export interface UserRecord {
oauthId?: string;
email?: string;
oauthInfo?: UserOAuthInfo;
picture: string;
imageServer: number;
iconUpdatedAt?: string;
thirdPartyUse: boolean;
deleteAfter?: string;
passwordHash: string;
passwordSalt: string;
createdAt: string;
@@ -18,6 +23,7 @@ export interface PublicUser {
username: string;
displayName: string;
roles: string[];
picture: string;
createdAt: string;
}
@@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
username: user.username,
displayName: user.displayName,
roles: user.roles,
picture: user.picture,
createdAt: user.createdAt,
});
@@ -70,6 +77,9 @@ export interface UserRepository {
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
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>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
deleteUser(userId: string): Promise<void>;
}
+4
View File
@@ -16,6 +16,8 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean;
orchestratorEnabled: boolean;
orchestratorReconcileIntervalMs: number;
@@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumberWithFallback(
+6
View File
@@ -18,6 +18,8 @@ export interface GatewayApiContext {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
@@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
userIconDir?: string;
userIconPublicUrl?: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
@@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: options.kakaoClient,
oauthSessions: options.oauthSessions,
publicBaseUrl: options.publicBaseUrl,
userIconDir: options.userIconDir ?? 'uploads/user-icons',
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
profiles: options.profiles,
orchestrator: options.orchestrator,
+8
View File
@@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
const zUsername = z.string().min(2).max(32);
const zPassword = z.string().min(6).max(128);
@@ -61,6 +62,7 @@ export const appRouter = router({
}),
}),
admin: adminRouter,
account: accountRouter,
auth: router({
bootstrapLocal: procedure
.input(
@@ -330,6 +332,12 @@ export const appRouter = router({
message: 'Invalid username or password.',
});
}
if (user.deleteAfter) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account deletion is pending.',
});
}
const ok = await ctx.users.verifyPassword(user, input.password);
if (!ok) {
throw new TRPCError({
+11
View File
@@ -1,5 +1,8 @@
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
createGatewayPostgresConnector,
@@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => {
origin: true,
credentials: true,
});
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
await app.register(fastifyStatic, {
root: path.resolve(process.cwd(), config.userIconDir),
prefix: '/user-icons/',
decorateReply: false,
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => {
kakaoClient,
oauthSessions,
publicBaseUrl: config.publicBaseUrl,
userIconDir: path.resolve(process.cwd(), config.userIconDir),
userIconPublicUrl: config.userIconPublicUrl,
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
profiles,
orchestrator,
+104 -2
View File
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
@@ -10,7 +14,7 @@ import { appRouter } from '../src/router.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
const buildCaller = () => {
const buildCaller = (options: { userIconDir?: string } = {}) => {
const users = createInMemoryUserRepository();
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 3600,
@@ -83,6 +87,8 @@ const buildCaller = () => {
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
oauthSessions,
publicBaseUrl: 'http://localhost',
userIconDir: options.userIconDir,
userIconPublicUrl: 'http://localhost/user-icons',
adminLocalAccountEnabled: false,
profiles,
orchestrator,
@@ -95,7 +101,7 @@ const buildCaller = () => {
} as unknown as GatewayPrismaClient,
})
);
return { caller, oauthSessions };
return { caller, oauthSessions, users, sessions };
};
describe('gateway auth flow', () => {
@@ -167,3 +173,99 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
});
describe('account self service', () => {
it('changes only the authenticated user password after verifying the current password', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'self-service',
password: 'current-password',
});
const session = await sessions.createSession(user);
await expect(
caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'wrong-password',
newPassword: 'next-password',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
newPassword: 'next-password',
});
const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
});
it('revokes the session and schedules deletion after 30 days', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'delete-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const result = await caller.account.scheduleDeletion({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
});
expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
expect((await users.findById(user.id))?.deleteAfter).toBe(result.deleteAfter);
expect(await sessions.getSession(session.sessionToken)).toBeNull();
});
it('revokes third-party use consent without allowing it to be re-enabled', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'privacy-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
await caller.account.disallowThirdPartyUse({ sessionToken: session.sessionToken });
expect((await users.findById(user.id))?.thirdPartyUse).toBe(false);
});
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 user = await users.createUser({
username: 'icon-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#334455',
},
})
.png()
.toBuffer();
const result = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(updated?.imageServer).toBe(1);
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',
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
});
+454 -431
View File
@@ -1,47 +1,47 @@
generator client {
provider = "prisma-client-js"
output = "./generated/game"
engineType = "binary"
provider = "prisma-client-js"
output = "./generated/game"
engineType = "binary"
}
datasource db {
provider = "postgresql"
provider = "postgresql"
}
enum LogScope {
SYSTEM
NATION
GENERAL
USER
SYSTEM
NATION
GENERAL
USER
}
enum LogCategory {
HISTORY
SUMMARY
ACTION
BATTLE_BRIEF
BATTLE_DETAIL
USER
HISTORY
SUMMARY
ACTION
BATTLE_BRIEF
BATTLE_DETAIL
USER
}
enum AuctionStatus {
OPEN
FINALIZING
FINISHED
CANCELED
OPEN
FINALIZING
FINISHED
CANCELED
}
enum AuctionType {
BUY_RICE
SELL_RICE
UNIQUE_ITEM
BUY_RICE
SELL_RICE
UNIQUE_ITEM
}
enum DiplomacyLetterState {
PROPOSED
ACTIVATED
CANCELLED
REPLACED
PROPOSED
ACTIVATED
CANCELLED
REPLACED
}
enum InputEventStatus {
@@ -78,538 +78,561 @@ model InputEvent {
}
model WorldState {
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
currentYear Int @map("current_year")
currentMonth Int @map("current_month")
tickSeconds Int @map("tick_seconds")
config Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
currentYear Int @map("current_year")
currentMonth Int @map("current_month")
tickSeconds Int @map("tick_seconds")
config Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@map("world_state")
@@map("world_state")
}
model Nation {
id Int @id
name String
color String
capitalCityId Int? @map("capital_city_id")
gold Int @default(0)
rice Int @default(0)
tech Float @default(0)
level Int @default(0)
typeCode String @default("che_중립") @map("type_code")
meta Json @default(dbgenerated("'{}'::jsonb"))
id Int @id
name String
color String
capitalCityId Int? @map("capital_city_id")
chiefGeneralId Int? @map("chief_general_id")
gold Int @default(0)
rice Int @default(0)
tech Float @default(0)
level Int @default(0)
typeCode String @default("che_중립") @map("type_code")
meta Json @default(dbgenerated("'{}'::jsonb"))
@@map("nation")
@@map("nation")
}
model City {
id Int @id
name String
level Int
nationId Int @default(0) @map("nation_id")
supplyState Int @default(1) @map("supply_state")
frontState Int @default(0) @map("front_state")
population Int @map("pop")
populationMax Int @map("pop_max")
agriculture Int @map("agri")
agricultureMax Int @map("agri_max")
commerce Int @map("comm")
commerceMax Int @map("comm_max")
security Int @map("secu")
securityMax Int @map("secu_max")
trust Int @default(0)
trade Int @default(100)
defence Int @map("def")
defenceMax Int @map("def_max")
wall Int @map("wall")
wallMax Int @map("wall_max")
region Int
conflict Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
id Int @id
name String
level Int
nationId Int @default(0) @map("nation_id")
supplyState Int @default(1) @map("supply_state")
frontState Int @default(0) @map("front_state")
population Int @map("pop")
populationMax Int @map("pop_max")
agriculture Int @map("agri")
agricultureMax Int @map("agri_max")
commerce Int @map("comm")
commerceMax Int @map("comm_max")
security Int @map("secu")
securityMax Int @map("secu_max")
trust Int @default(0)
trade Int @default(100)
defence Int @map("def")
defenceMax Int @map("def_max")
wall Int @map("wall")
wallMax Int @map("wall_max")
region Int
conflict Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
@@map("city")
@@map("city")
}
model General {
id Int @id
userId String? @map("user_id")
name String
nationId Int @default(0) @map("nation_id")
cityId Int @default(0) @map("city_id")
troopId Int @default(0) @map("troop_id")
npcState Int @default(0) @map("npc_state")
affinity Int?
bornYear Int @default(180) @map("born_year")
deadYear Int @default(300) @map("dead_year")
picture String?
imageServer Int @default(0) @map("image_server")
leadership Int @default(50)
strength Int @default(50)
intel Int @default(50)
injury Int @default(0)
experience Int @default(0)
dedication Int @default(0)
officerLevel Int @default(0) @map("officer_level")
gold Int @default(1000)
rice Int @default(1000)
crew Int @default(0)
crewTypeId Int @default(0) @map("crew_type_id")
train Int @default(0)
atmos Int @default(0)
weaponCode String @default("None") @map("weapon_code")
bookCode String @default("None") @map("book_code")
horseCode String @default("None") @map("horse_code")
itemCode String @default("None") @map("item_code")
turnTime DateTime @map("turn_time")
recentWarTime DateTime? @map("recent_war_time")
age Int @default(20)
startAge Int @default(20) @map("start_age")
personalCode String @default("None") @map("personal_code")
specialCode String @default("None") @map("special_code")
special2Code String @default("None") @map("special2_code")
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
meta Json @default(dbgenerated("'{}'::jsonb"))
penalty Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at")
id Int @id
userId String? @map("user_id")
name String
nationId Int @default(0) @map("nation_id")
cityId Int @default(0) @map("city_id")
troopId Int @default(0) @map("troop_id")
npcState Int @default(0) @map("npc_state")
affinity Int?
bornYear Int @default(180) @map("born_year")
deadYear Int @default(300) @map("dead_year")
picture String?
imageServer Int @default(0) @map("image_server")
leadership Int @default(50)
strength Int @default(50)
intel Int @default(50)
injury Int @default(0)
experience Int @default(0)
dedication Int @default(0)
officerLevel Int @default(0) @map("officer_level")
gold Int @default(1000)
rice Int @default(1000)
crew Int @default(0)
crewTypeId Int @default(0) @map("crew_type_id")
train Int @default(0)
atmos Int @default(0)
weaponCode String @default("None") @map("weapon_code")
bookCode String @default("None") @map("book_code")
horseCode String @default("None") @map("horse_code")
itemCode String @default("None") @map("item_code")
turnTime DateTime @map("turn_time")
recentWarTime DateTime? @map("recent_war_time")
age Int @default(20)
startAge Int @default(20) @map("start_age")
personalCode String @default("None") @map("personal_code")
specialCode String @default("None") @map("special_code")
special2Code String @default("None") @map("special2_code")
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
meta Json @default(dbgenerated("'{}'::jsonb"))
penalty Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at")
@@map("general")
@@map("general")
}
model GeneralAccessLog {
id Int @id @default(autoincrement())
generalId Int @unique @map("general_id")
userId String? @map("user_id")
lastRefresh DateTime? @map("last_refresh")
refresh Int @default(0)
refreshTotal Int @default(0) @map("refresh_total")
refreshScore Int @default(0) @map("refresh_score")
refreshScoreTotal Int @default(0) @map("refresh_score_total")
id Int @id @default(autoincrement())
generalId Int @unique @map("general_id")
userId String? @map("user_id")
lastRefresh DateTime? @map("last_refresh")
refresh Int @default(0)
refreshTotal Int @default(0) @map("refresh_total")
refreshScore Int @default(0) @map("refresh_score")
refreshScoreTotal Int @default(0) @map("refresh_score_total")
@@index([userId])
@@map("general_access_log")
@@index([userId])
@@map("general_access_log")
}
model MessageReadState {
generalId Int @id @map("general_id")
latestPrivateMessage Int @default(0) @map("latest_private_message")
latestDiplomacyMessage Int @default(0) @map("latest_diplomacy_message")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("message_read_state")
}
model Message {
id Int @id @default(autoincrement())
mailbox Int
type String
src Int
dest Int
time DateTime
validUntil DateTime @map("valid_until")
message Json
@@map("message")
}
model RankData {
id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id")
generalId Int @map("general_id")
type String @db.VarChar(20)
value Int @default(0)
id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id")
generalId Int @map("general_id")
type String @db.VarChar(20)
value Int @default(0)
@@unique([generalId, type])
@@index([type, value], name: "by_type")
@@index([nationId, type, value], name: "by_nation")
@@map("rank_data")
@@unique([generalId, type])
@@index([type, value], name: "by_type")
@@index([nationId, type, value], name: "by_nation")
@@map("rank_data")
}
model HallOfFame {
id Int @id @default(autoincrement())
serverId String @map("server_id")
season Int
scenario Int
generalNo Int @map("general_no")
type String @db.VarChar(20)
value Float
owner String? @map("owner")
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
season Int
scenario Int
generalNo Int @map("general_no")
type String @db.VarChar(20)
value Float
owner String? @map("owner")
aux Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId, type, generalNo])
@@unique([owner, serverId, type], name: "hall_owner")
@@index([serverId, type, value], name: "server_show")
@@index([season, scenario, type, value], name: "scenario")
@@map("hall")
@@unique([serverId, type, generalNo])
@@unique([owner, serverId, type], name: "hall_owner")
@@index([serverId, type, value], name: "server_show")
@@index([season, scenario, type, value], name: "scenario")
@@map("hall")
}
model GameHistory {
id Int @id @default(autoincrement())
serverId String @map("server_id")
date DateTime
winnerNation Int? @map("winner_nation")
map String? @map("map")
season Int
scenario Int
scenarioName String @map("scenario_name")
env Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
date DateTime
winnerNation Int? @map("winner_nation")
map String? @map("map")
season Int
scenario Int
scenarioName String @map("scenario_name")
env Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId])
@@index([date])
@@map("ng_games")
@@unique([serverId])
@@index([date])
@@map("ng_games")
}
model OldNation {
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
@@unique([serverId, nation])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
@@unique([serverId, nation])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
}
model OldGeneral {
id Int @id @default(autoincrement())
serverId String @map("server_id")
generalNo Int @map("general_no")
owner String? @map("owner")
name String
lastYearMonth Int @map("last_yearmonth")
turnTime DateTime @map("turntime")
data Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
generalNo Int @map("general_no")
owner String? @map("owner")
name String
lastYearMonth Int @map("last_yearmonth")
turnTime DateTime @map("turntime")
data Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId, generalNo], name: "by_no")
@@index([serverId, name], name: "by_name")
@@index([owner, serverId], name: "owner")
@@map("ng_old_generals")
@@unique([serverId, generalNo], name: "by_no")
@@index([serverId, name], name: "by_name")
@@index([owner, serverId], name: "owner")
@@map("ng_old_generals")
}
model Emperor {
id Int @id @default(autoincrement()) @map("no")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
nationName String? @map("nation_name")
nationHist String? @map("nation_hist")
genCount String? @map("gen_count")
personalHist String? @map("personal_hist")
specialHist String? @map("special_hist")
name String? @default("")
type String? @default("")
color String? @default("")
year Int? @default(0)
month Int? @default(0)
power Int? @default(0)
gennum Int? @default(0)
citynum Int? @default(0)
pop String? @default("0")
poprate String? @default("")
gold Int? @default(0)
rice Int? @default(0)
l12name String? @default("")
l12pic String? @default("")
l11name String? @default("")
l11pic String? @default("")
l10name String? @default("")
l10pic String? @default("")
l9name String? @default("")
l9pic String? @default("")
l8name String? @default("")
l8pic String? @default("")
l7name String? @default("")
l7pic String? @default("")
l6name String? @default("")
l6pic String? @default("")
l5name String? @default("")
l5pic String? @default("")
tiger String? @default("")
eagle String? @default("")
gen String? @default("")
history Json @default(dbgenerated("'{}'::jsonb"))
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement()) @map("no")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
nationName String? @map("nation_name")
nationHist String? @map("nation_hist")
genCount String? @map("gen_count")
personalHist String? @map("personal_hist")
specialHist String? @map("special_hist")
name String? @default("")
type String? @default("")
color String? @default("")
year Int? @default(0)
month Int? @default(0)
power Int? @default(0)
gennum Int? @default(0)
citynum Int? @default(0)
pop String? @default("0")
poprate String? @default("")
gold Int? @default(0)
rice Int? @default(0)
l12name String? @default("")
l12pic String? @default("")
l11name String? @default("")
l11pic String? @default("")
l10name String? @default("")
l10pic String? @default("")
l9name String? @default("")
l9pic String? @default("")
l8name String? @default("")
l8pic String? @default("")
l7name String? @default("")
l7pic String? @default("")
l6name String? @default("")
l6pic String? @default("")
l5name String? @default("")
l5pic String? @default("")
tiger String? @default("")
eagle String? @default("")
gen String? @default("")
history Json @default(dbgenerated("'{}'::jsonb"))
aux Json @default(dbgenerated("'{}'::jsonb"))
@@map("emperior")
@@map("emperior")
}
model Troop {
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
name String
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
name String
@@map("troop")
@@map("troop")
}
model GeneralTurn {
id Int @id @default(autoincrement())
generalId Int @map("general_id")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
generalId Int @map("general_id")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([generalId, turnIdx])
@@map("general_turn")
@@unique([generalId, turnIdx])
@@map("general_turn")
}
model NationTurn {
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
officerLevel Int @map("officer_level")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
officerLevel Int @map("officer_level")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([nationId, officerLevel, turnIdx])
@@map("nation_turn")
@@unique([nationId, officerLevel, turnIdx])
@@map("nation_turn")
}
model Diplomacy {
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
stateCode Int @map("state_code")
term Int @default(0)
isDead Boolean @default(false) @map("is_dead")
isShowing Boolean @default(true) @map("is_showing")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
stateCode Int @map("state_code")
term Int @default(0)
isDead Boolean @default(false) @map("is_dead")
isShowing Boolean @default(true) @map("is_showing")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([srcNationId, destNationId])
@@map("diplomacy")
@@unique([srcNationId, destNationId])
@@map("diplomacy")
}
model DiplomacyLetter {
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
prevId Int? @map("prev_id")
state DiplomacyLetterState @default(PROPOSED)
textBrief String @map("text_brief")
textDetail String @map("text_detail")
date DateTime @default(now()) @map("date")
srcSignerId Int @map("src_signer")
destSignerId Int? @map("dest_signer")
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
prevId Int? @map("prev_id")
state DiplomacyLetterState @default(PROPOSED)
textBrief String @map("text_brief")
textDetail String @map("text_detail")
date DateTime @default(now()) @map("date")
srcSignerId Int @map("src_signer")
destSignerId Int? @map("dest_signer")
aux Json @default(dbgenerated("'{}'::jsonb"))
@@index([srcNationId, destNationId])
@@index([destNationId, srcNationId])
@@index([state, date])
@@map("diplomacy_letter")
@@index([srcNationId, destNationId])
@@index([destNationId, srcNationId])
@@index([state, date])
@@map("diplomacy_letter")
}
model YearbookHistory {
id Int @id @default(autoincrement())
profileName String @map("profile_name")
year Int
month Int
map Json
nations Json
hash String @default("")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
profileName String @map("profile_name")
year Int
month Int
map Json
nations Json
hash String @default("")
createdAt DateTime @default(now()) @map("created_at")
@@unique([profileName, year, month])
@@index([profileName, year, month])
@@map("yearbook_history")
@@unique([profileName, year, month])
@@index([profileName, year, month])
@@map("yearbook_history")
}
model Event {
id Int @id @default(autoincrement())
targetCode String @map("target_code")
priority Int @default(0)
condition Json @default(dbgenerated("'{}'::jsonb"))
action Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
targetCode String @map("target_code")
priority Int @default(0)
condition Json @default(dbgenerated("'{}'::jsonb"))
action Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@map("event")
@@map("event")
}
model LogEntry {
id Int @id @default(autoincrement())
scope LogScope
category LogCategory
subType String? @map("sub_type")
year Int
month Int
text String
generalId Int? @map("general_id")
nationId Int? @map("nation_id")
userId Int? @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
scope LogScope
category LogCategory
subType String? @map("sub_type")
year Int
month Int
text String
generalId Int? @map("general_id")
nationId Int? @map("nation_id")
userId Int? @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([scope, category, id])
@@index([generalId, category, id])
@@index([nationId, category, id])
@@index([userId, category, id])
@@map("log_entry")
@@index([scope, category, id])
@@index([generalId, category, id])
@@index([nationId, category, id])
@@index([userId, category, id])
@@map("log_entry")
}
model ErrorLog {
id Int @id @default(autoincrement())
category String
source String? @map("source")
message String
trace String?
context Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
category String
source String? @map("source")
message String
trace String?
context Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([category, id])
@@map("error_log")
@@index([category, id])
@@map("error_log")
}
model InheritancePoint {
id Int @id @default(autoincrement())
userId String @map("user_id")
key String
value Float @default(0)
aux Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
key String
value Float @default(0)
aux Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, key])
@@index([userId])
@@map("inheritance_point")
@@unique([userId, key])
@@index([userId])
@@map("inheritance_point")
}
model InheritanceLog {
id Int @id @default(autoincrement())
userId String @map("user_id")
year Int
month Int
text String
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
year Int
month Int
text String
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, id])
@@map("inheritance_log")
@@index([userId, id])
@@map("inheritance_log")
}
model InheritanceResult {
id Int @id @default(autoincrement())
serverId String @map("server_id")
owner String @map("owner")
generalId Int @map("general_id")
year Int
month Int
value Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
serverId String @map("server_id")
owner String @map("owner")
generalId Int @map("general_id")
year Int
month Int
value Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([serverId, owner])
@@map("inheritance_result")
@@index([serverId, owner])
@@map("inheritance_result")
}
model Auction {
id Int @id @default(autoincrement())
type AuctionType
targetCode String? @map("target_code")
hostGeneralId Int @map("host_general_id")
hostName String? @map("host_name")
detail Json @default(dbgenerated("'{}'::jsonb"))
status AuctionStatus @default(OPEN)
closeAt DateTime @map("close_at")
latestEventId String @default("") @map("latest_event_id")
latestEventAt DateTime @default(now()) @map("latest_event_at")
finalizingAt DateTime? @map("finalizing_at")
finishedAt DateTime? @map("finished_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
type AuctionType
targetCode String? @map("target_code")
hostGeneralId Int @map("host_general_id")
hostName String? @map("host_name")
detail Json @default(dbgenerated("'{}'::jsonb"))
status AuctionStatus @default(OPEN)
closeAt DateTime @map("close_at")
latestEventId String @default("") @map("latest_event_id")
latestEventAt DateTime @default(now()) @map("latest_event_at")
finalizingAt DateTime? @map("finalizing_at")
finishedAt DateTime? @map("finished_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
bids AuctionBid[]
bids AuctionBid[]
@@index([status, closeAt])
@@map("auction")
@@index([status, closeAt])
@@map("auction")
}
model AuctionBid {
id Int @id @default(autoincrement())
auctionId Int @map("auction_id")
generalId Int @map("general_id")
amount Int
eventId String @map("event_id")
eventAt DateTime @map("event_at")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
auctionId Int @map("auction_id")
generalId Int @map("general_id")
amount Int
eventId String @map("event_id")
eventAt DateTime @map("event_at")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
@@index([auctionId, amount])
@@index([auctionId, eventAt])
@@map("auction_bid")
@@index([auctionId, amount])
@@index([auctionId, eventAt])
@@map("auction_bid")
}
model InheritanceUserState {
userId String @id @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
userId String @id @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@map("inheritance_user_state")
@@map("inheritance_user_state")
}
model BoardPost {
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
title String
contentHtml String @map("content_html")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
title String
contentHtml String @map("content_html")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
comments BoardComment[]
comments BoardComment[]
@@index([nationId, isSecret, createdAt])
@@map("board_post")
@@index([nationId, isSecret, createdAt])
@@map("board_post")
}
model BoardComment {
id Int @id @default(autoincrement())
postId Int @map("post_id")
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
contentText String @map("content_text")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
postId Int @map("post_id")
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
contentText String @map("content_text")
createdAt DateTime @default(now()) @map("created_at")
post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade)
post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade)
@@index([postId, createdAt])
@@map("board_comment")
@@index([postId, createdAt])
@@map("board_comment")
}
model VotePoll {
id Int @id @default(autoincrement())
title String
body String @default("")
options Json
multipleOptions Int @default(1) @map("multiple_options")
revealMode String @map("reveal_mode")
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
endAt DateTime? @map("end_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
title String
body String @default("")
options Json
multipleOptions Int @default(1) @map("multiple_options")
revealMode String @map("reveal_mode")
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
endAt DateTime? @map("end_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
votes Vote[]
comments VoteComment[]
votes Vote[]
comments VoteComment[]
@@map("vote_poll")
@@map("vote_poll")
}
model Vote {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
selection Json
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
selection Json
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@unique([voteId, generalId])
@@index([voteId])
@@map("vote")
@@unique([voteId, generalId])
@@index([voteId])
@@map("vote")
}
model VoteComment {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
generalName String @map("general_name")
nationName String @map("nation_name")
text String
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
generalName String @map("general_name")
nationName String @map("nation_name")
text String
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@index([voteId, createdAt])
@@map("vote_comment")
@@index([voteId, createdAt])
@@map("vote_comment")
}
@@ -0,0 +1,6 @@
ALTER TABLE "app_user"
ADD COLUMN "picture" TEXT NOT NULL DEFAULT 'default.jpg',
ADD COLUMN "image_server" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "icon_updated_at" TIMESTAMP(3),
ADD COLUMN "third_party_use" BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN "delete_after" TIMESTAMP(3);
+63 -58
View File
@@ -1,84 +1,89 @@
generator client {
provider = "prisma-client-js"
output = "./generated/gateway"
engineType = "binary"
provider = "prisma-client-js"
output = "./generated/gateway"
engineType = "binary"
}
datasource db {
provider = "postgresql"
provider = "postgresql"
}
enum OAuthType {
NONE
KAKAO
NONE
KAKAO
}
enum GatewayProfileStatus {
RESERVED
PREOPEN
RUNNING
PAUSED
COMPLETED
STOPPED
DISABLED
RESERVED
PREOPEN
RUNNING
PAUSED
COMPLETED
STOPPED
DISABLED
}
enum GatewayBuildStatus {
IDLE
QUEUED
RUNNING
FAILED
SUCCEEDED
IDLE
QUEUED
RUNNING
FAILED
SUCCEEDED
}
model AppUser {
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
@@map("app_user")
@@map("app_user")
}
model GatewayProfile {
profileName String @id @map("profile_name")
profile String
scenario String
apiPort Int @map("api_port")
status GatewayProfileStatus
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
buildCommitSha String? @map("build_commit_sha")
buildWorkspace String? @map("build_workspace")
buildLastUsedAt DateTime? @map("build_last_used_at")
preopenAt DateTime? @map("preopen_at")
openAt DateTime? @map("open_at")
scheduledStartAt DateTime? @map("scheduled_start_at")
buildRequestedAt DateTime? @map("build_requested_at")
buildStartedAt DateTime? @map("build_started_at")
buildCompletedAt DateTime? @map("build_completed_at")
buildError String? @map("build_error")
lastError String? @map("last_error")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
profileName String @id @map("profile_name")
profile String
scenario String
apiPort Int @map("api_port")
status GatewayProfileStatus
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
buildCommitSha String? @map("build_commit_sha")
buildWorkspace String? @map("build_workspace")
buildLastUsedAt DateTime? @map("build_last_used_at")
preopenAt DateTime? @map("preopen_at")
openAt DateTime? @map("open_at")
scheduledStartAt DateTime? @map("scheduled_start_at")
buildRequestedAt DateTime? @map("build_requested_at")
buildStartedAt DateTime? @map("build_started_at")
buildCompletedAt DateTime? @map("build_completed_at")
buildError String? @map("build_error")
lastError String? @map("last_error")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([profile, scenario])
@@map("gateway_profile")
@@unique([profile, scenario])
@@map("gateway_profile")
}
model SystemSetting {
id Int @id @default(1) @map("no")
notice String @default("") @map("notice")
id Int @id @default(1) @map("no")
notice String @default("") @map("notice")
@@map("system")
@@map("system")
}
@@ -0,0 +1,6 @@
CREATE TABLE "message_read_state" (
"general_id" INTEGER PRIMARY KEY,
"latest_private_message" INTEGER NOT NULL DEFAULT 0,
"latest_diplomacy_message" INTEGER NOT NULL DEFAULT 0,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,2 @@
ALTER TABLE "nation"
ADD COLUMN "chief_general_id" INTEGER;
+2
View File
@@ -7,6 +7,8 @@ export interface DatabaseClient {
worldState: GamePrisma.WorldStateDelegate;
general: GamePrisma.GeneralDelegate;
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate;
city: GamePrisma.CityDelegate;
nation: GamePrisma.NationDelegate;
diplomacy: GamePrisma.DiplomacyDelegate;
+2
View File
@@ -84,6 +84,7 @@ export interface TurnEngineNationRow {
name: string;
color: string;
capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number;
rice: number;
tech: number;
@@ -287,6 +288,7 @@ export interface TurnEngineNationCreateManyInput {
name: string;
color: string;
capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number;
rice: number;
tech: number;
+6
View File
@@ -233,6 +233,9 @@ importers:
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
'@prisma/client':
specifier: ^7.2.0
version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2)
@@ -266,6 +269,9 @@ importers:
redis:
specifier: ^5.10.0
version: 5.10.0
sharp:
specifier: ^0.34.4
version: 0.34.5
zod:
specifier: ^4.3.5
version: 4.3.5
@@ -295,7 +295,7 @@ describe('auction integration flow', () => {
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: adminSessionRef.value ?? '',
profile: 'che:2',
profile: 'che:908',
});
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
gatewayToken: adminGatewayToken.gameToken,