feat: complete user-facing message and account APIs
This commit is contained in:
@@ -26,6 +26,8 @@ GATEWAY_REDIS_PREFIX=sammo:gateway
|
|||||||
GATEWAY_DB_SCHEMA=public
|
GATEWAY_DB_SCHEMA=public
|
||||||
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||||
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
|
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
|
SESSION_TTL_SECONDS=604800
|
||||||
GAME_SESSION_TTL_SECONDS=21600
|
GAME_SESSION_TTL_SECONDS=21600
|
||||||
OAUTH_SESSION_TTL_SECONDS=600
|
OAUTH_SESSION_TTL_SECONDS=600
|
||||||
|
|||||||
@@ -164,3 +164,4 @@ docker-compose.override.yml
|
|||||||
docs/image-storage.md
|
docs/image-storage.md
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
uploads/
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ interface MessageRow {
|
|||||||
message: unknown;
|
message: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoredMessage {
|
||||||
|
id: number;
|
||||||
|
mailbox: number;
|
||||||
|
msgType: MessageType;
|
||||||
|
time: Date;
|
||||||
|
payload: MessagePayload;
|
||||||
|
}
|
||||||
|
|
||||||
const parsePayload = (value: unknown): MessagePayload => {
|
const parsePayload = (value: unknown): MessagePayload => {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return JSON.parse(value) as MessagePayload;
|
return JSON.parse(value) as MessagePayload;
|
||||||
@@ -113,3 +121,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
|||||||
|
|
||||||
return rows.map(toMessageView);
|
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() },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '..
|
|||||||
import {
|
import {
|
||||||
fetchMessagesFromMailbox,
|
fetchMessagesFromMailbox,
|
||||||
fetchOldMessagesFromMailbox,
|
fetchOldMessagesFromMailbox,
|
||||||
|
fetchMessageById,
|
||||||
|
invalidateMessages,
|
||||||
insertMessage,
|
insertMessage,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from '../../messages/store.js';
|
} from '../../messages/store.js';
|
||||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
|
import { resolveNationPermission } from '../nation/shared.js';
|
||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
@@ -42,36 +45,39 @@ export const messagesRouter = router({
|
|||||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||||
} satisfies Record<MessageType, number>;
|
} satisfies Record<MessageType, number>;
|
||||||
|
|
||||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([
|
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
|
||||||
fetchMessagesFromMailbox({
|
[
|
||||||
db: ctx.db,
|
fetchMessagesFromMailbox({
|
||||||
mailbox: mailboxes.private,
|
db: ctx.db,
|
||||||
msgType: 'private',
|
mailbox: mailboxes.private,
|
||||||
limit: 15,
|
msgType: 'private',
|
||||||
fromSeq: sequence,
|
limit: 15,
|
||||||
}),
|
fromSeq: sequence,
|
||||||
fetchMessagesFromMailbox({
|
}),
|
||||||
db: ctx.db,
|
fetchMessagesFromMailbox({
|
||||||
mailbox: mailboxes.public,
|
db: ctx.db,
|
||||||
msgType: 'public',
|
mailbox: mailboxes.public,
|
||||||
limit: 15,
|
msgType: 'public',
|
||||||
fromSeq: sequence,
|
limit: 15,
|
||||||
}),
|
fromSeq: sequence,
|
||||||
fetchMessagesFromMailbox({
|
}),
|
||||||
db: ctx.db,
|
fetchMessagesFromMailbox({
|
||||||
mailbox: mailboxes.national,
|
db: ctx.db,
|
||||||
msgType: 'national',
|
mailbox: mailboxes.national,
|
||||||
limit: 15,
|
msgType: 'national',
|
||||||
fromSeq: sequence,
|
limit: 15,
|
||||||
}),
|
fromSeq: sequence,
|
||||||
fetchMessagesFromMailbox({
|
}),
|
||||||
db: ctx.db,
|
fetchMessagesFromMailbox({
|
||||||
mailbox: mailboxes.diplomacy,
|
db: ctx.db,
|
||||||
msgType: 'diplomacy',
|
mailbox: mailboxes.diplomacy,
|
||||||
limit: 15,
|
msgType: 'diplomacy',
|
||||||
fromSeq: sequence,
|
limit: 15,
|
||||||
}),
|
fromSeq: sequence,
|
||||||
]);
|
}),
|
||||||
|
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||||
private: privateMessages,
|
private: privateMessages,
|
||||||
@@ -117,11 +123,118 @@ export const messagesRouter = router({
|
|||||||
nationId: nationId,
|
nationId: nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
latestRead: {
|
latestRead: {
|
||||||
diplomacy: 0,
|
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||||
private: 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
|
getOld: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -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,
|
name: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
capitalCityId: nation.capitalCityId,
|
capitalCityId: nation.capitalCityId,
|
||||||
|
chiefGeneralId: nation.chiefGeneralId,
|
||||||
gold: nation.gold,
|
gold: nation.gold,
|
||||||
rice: nation.rice,
|
rice: nation.rice,
|
||||||
level: nation.level,
|
level: nation.level,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
color: row.color,
|
color: row.color,
|
||||||
capitalCityId: row.capitalCityId,
|
capitalCityId: row.capitalCityId,
|
||||||
chiefGeneralId: null,
|
chiefGeneralId: row.chiefGeneralId,
|
||||||
gold: row.gold,
|
gold: row.gold,
|
||||||
rice: row.rice,
|
rice: row.rice,
|
||||||
power: 0,
|
power: 0,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@sammo-ts/common": "workspace:*",
|
"@sammo-ts/common": "workspace:*",
|
||||||
"@sammo-ts/game-engine": "workspace:*",
|
"@sammo-ts/game-engine": "workspace:*",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"pm2": "^5.4.3",
|
"pm2": "^5.4.3",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
oauthId: input.oauth?.id,
|
||||||
email: input.oauth?.email,
|
email: input.oauth?.email,
|
||||||
oauthInfo: input.oauth?.info,
|
oauthInfo: input.oauth?.info,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
thirdPartyUse: true,
|
||||||
passwordSalt: salt,
|
passwordSalt: salt,
|
||||||
passwordHash: hasher.hash(input.password, salt),
|
passwordHash: hasher.hash(input.password, salt),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
|||||||
}
|
}
|
||||||
throw new Error('User not found.');
|
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> {
|
async deleteUser(userId: string): Promise<void> {
|
||||||
for (const [username, user] of usersByName.entries()) {
|
for (const [username, user] of usersByName.entries()) {
|
||||||
if (user.id === userId) {
|
if (user.id === userId) {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const mapUser = (row: {
|
|||||||
oauthId: string | null;
|
oauthId: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
oauthInfo: GatewayPrisma.JsonValue;
|
oauthInfo: GatewayPrisma.JsonValue;
|
||||||
|
picture: string;
|
||||||
|
imageServer: number;
|
||||||
|
iconUpdatedAt: Date | null;
|
||||||
|
thirdPartyUse: boolean;
|
||||||
|
deleteAfter: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): UserRecord => ({
|
}): UserRecord => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -40,6 +45,11 @@ const mapUser = (row: {
|
|||||||
oauthId: row.oauthId ?? undefined,
|
oauthId: row.oauthId ?? undefined,
|
||||||
email: row.email ?? undefined,
|
email: row.email ?? undefined,
|
||||||
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
|
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,
|
passwordHash: row.passwordHash,
|
||||||
passwordSalt: row.passwordSalt,
|
passwordSalt: row.passwordSalt,
|
||||||
createdAt: row.createdAt.toISOString(),
|
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> {
|
async deleteUser(userId: string): Promise<void> {
|
||||||
await prisma.appUser.delete({
|
await prisma.appUser.delete({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface UserRecord {
|
|||||||
oauthId?: string;
|
oauthId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
oauthInfo?: UserOAuthInfo;
|
oauthInfo?: UserOAuthInfo;
|
||||||
|
picture: string;
|
||||||
|
imageServer: number;
|
||||||
|
iconUpdatedAt?: string;
|
||||||
|
thirdPartyUse: boolean;
|
||||||
|
deleteAfter?: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -18,6 +23,7 @@ export interface PublicUser {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
picture: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
|
picture: user.picture,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +77,9 @@ export interface UserRepository {
|
|||||||
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
|
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
|
||||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||||
updateSanctions(userId: string, sanctions: UserSanctions): 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>;
|
deleteUser(userId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface GatewayApiConfig {
|
|||||||
kakaoAdminKey?: string;
|
kakaoAdminKey?: string;
|
||||||
kakaoRedirectUri: string;
|
kakaoRedirectUri: string;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
userIconDir: string;
|
||||||
|
userIconPublicUrl: string;
|
||||||
adminLocalAccountEnabled: boolean;
|
adminLocalAccountEnabled: boolean;
|
||||||
orchestratorEnabled: boolean;
|
orchestratorEnabled: boolean;
|
||||||
orchestratorReconcileIntervalMs: number;
|
orchestratorReconcileIntervalMs: number;
|
||||||
@@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
|||||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||||
kakaoRedirectUri,
|
kakaoRedirectUri,
|
||||||
publicBaseUrl,
|
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),
|
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||||
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface GatewayApiContext {
|
|||||||
kakaoClient: KakaoOAuthClient;
|
kakaoClient: KakaoOAuthClient;
|
||||||
oauthSessions: OAuthSessionStore;
|
oauthSessions: OAuthSessionStore;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
userIconDir: string;
|
||||||
|
userIconPublicUrl: string;
|
||||||
adminLocalAccountEnabled: boolean;
|
adminLocalAccountEnabled: boolean;
|
||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
@@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: {
|
|||||||
kakaoClient: KakaoOAuthClient;
|
kakaoClient: KakaoOAuthClient;
|
||||||
oauthSessions: OAuthSessionStore;
|
oauthSessions: OAuthSessionStore;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
|
userIconDir?: string;
|
||||||
|
userIconPublicUrl?: string;
|
||||||
adminLocalAccountEnabled: boolean;
|
adminLocalAccountEnabled: boolean;
|
||||||
profiles: GatewayProfileRepository;
|
profiles: GatewayProfileRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
@@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: {
|
|||||||
kakaoClient: options.kakaoClient,
|
kakaoClient: options.kakaoClient,
|
||||||
oauthSessions: options.oauthSessions,
|
oauthSessions: options.oauthSessions,
|
||||||
publicBaseUrl: options.publicBaseUrl,
|
publicBaseUrl: options.publicBaseUrl,
|
||||||
|
userIconDir: options.userIconDir ?? 'uploads/user-icons',
|
||||||
|
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||||
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||||
profiles: options.profiles,
|
profiles: options.profiles,
|
||||||
orchestrator: options.orchestrator,
|
orchestrator: options.orchestrator,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js';
|
|||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
import type { UserOAuthInfo } from './auth/userRepository.js';
|
import type { UserOAuthInfo } from './auth/userRepository.js';
|
||||||
import { adminRouter } from './adminRouter.js';
|
import { adminRouter } from './adminRouter.js';
|
||||||
|
import { accountRouter } from './account/router.js';
|
||||||
|
|
||||||
const zUsername = z.string().min(2).max(32);
|
const zUsername = z.string().min(2).max(32);
|
||||||
const zPassword = z.string().min(6).max(128);
|
const zPassword = z.string().min(6).max(128);
|
||||||
@@ -61,6 +62,7 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
|
account: accountRouter,
|
||||||
auth: router({
|
auth: router({
|
||||||
bootstrapLocal: procedure
|
bootstrapLocal: procedure
|
||||||
.input(
|
.input(
|
||||||
@@ -330,6 +332,12 @@ export const appRouter = router({
|
|||||||
message: 'Invalid username or password.',
|
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);
|
const ok = await ctx.users.verifyPassword(user, input.password);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import fastify, { type FastifyRequest } from 'fastify';
|
import fastify, { type FastifyRequest } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
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 { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import {
|
||||||
createGatewayPostgresConnector,
|
createGatewayPostgresConnector,
|
||||||
@@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => {
|
|||||||
origin: true,
|
origin: true,
|
||||||
credentials: 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, {
|
await app.register(fastifyTRPCPlugin, {
|
||||||
prefix: config.trpcPath,
|
prefix: config.trpcPath,
|
||||||
@@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => {
|
|||||||
kakaoClient,
|
kakaoClient,
|
||||||
oauthSessions,
|
oauthSessions,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
userIconDir: path.resolve(process.cwd(), config.userIconDir),
|
||||||
|
userIconPublicUrl: config.userIconPublicUrl,
|
||||||
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||||
profiles,
|
profiles,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.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 type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
const buildCaller = () => {
|
const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||||
const users = createInMemoryUserRepository();
|
const users = createInMemoryUserRepository();
|
||||||
const sessions = new InMemoryGatewaySessionService({
|
const sessions = new InMemoryGatewaySessionService({
|
||||||
sessionTtlSeconds: 3600,
|
sessionTtlSeconds: 3600,
|
||||||
@@ -83,6 +87,8 @@ const buildCaller = () => {
|
|||||||
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
|
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
|
||||||
oauthSessions,
|
oauthSessions,
|
||||||
publicBaseUrl: 'http://localhost',
|
publicBaseUrl: 'http://localhost',
|
||||||
|
userIconDir: options.userIconDir,
|
||||||
|
userIconPublicUrl: 'http://localhost/user-icons',
|
||||||
adminLocalAccountEnabled: false,
|
adminLocalAccountEnabled: false,
|
||||||
profiles,
|
profiles,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
@@ -95,7 +101,7 @@ const buildCaller = () => {
|
|||||||
} as unknown as GatewayPrismaClient,
|
} as unknown as GatewayPrismaClient,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return { caller, oauthSessions };
|
return { caller, oauthSessions, users, sessions };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('gateway auth flow', () => {
|
describe('gateway auth flow', () => {
|
||||||
@@ -167,3 +173,99 @@ describe('gateway auth flow', () => {
|
|||||||
expect(validated?.user.username).toBe('tester');
|
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
@@ -1,47 +1,47 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "./generated/game"
|
output = "./generated/game"
|
||||||
engineType = "binary"
|
engineType = "binary"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogScope {
|
enum LogScope {
|
||||||
SYSTEM
|
SYSTEM
|
||||||
NATION
|
NATION
|
||||||
GENERAL
|
GENERAL
|
||||||
USER
|
USER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogCategory {
|
enum LogCategory {
|
||||||
HISTORY
|
HISTORY
|
||||||
SUMMARY
|
SUMMARY
|
||||||
ACTION
|
ACTION
|
||||||
BATTLE_BRIEF
|
BATTLE_BRIEF
|
||||||
BATTLE_DETAIL
|
BATTLE_DETAIL
|
||||||
USER
|
USER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuctionStatus {
|
enum AuctionStatus {
|
||||||
OPEN
|
OPEN
|
||||||
FINALIZING
|
FINALIZING
|
||||||
FINISHED
|
FINISHED
|
||||||
CANCELED
|
CANCELED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuctionType {
|
enum AuctionType {
|
||||||
BUY_RICE
|
BUY_RICE
|
||||||
SELL_RICE
|
SELL_RICE
|
||||||
UNIQUE_ITEM
|
UNIQUE_ITEM
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DiplomacyLetterState {
|
enum DiplomacyLetterState {
|
||||||
PROPOSED
|
PROPOSED
|
||||||
ACTIVATED
|
ACTIVATED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
REPLACED
|
REPLACED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum InputEventStatus {
|
enum InputEventStatus {
|
||||||
@@ -78,538 +78,561 @@ model InputEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WorldState {
|
model WorldState {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
scenarioCode String @map("scenario_code")
|
scenarioCode String @map("scenario_code")
|
||||||
currentYear Int @map("current_year")
|
currentYear Int @map("current_year")
|
||||||
currentMonth Int @map("current_month")
|
currentMonth Int @map("current_month")
|
||||||
tickSeconds Int @map("tick_seconds")
|
tickSeconds Int @map("tick_seconds")
|
||||||
config Json @default(dbgenerated("'{}'::jsonb"))
|
config Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("world_state")
|
@@map("world_state")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Nation {
|
model Nation {
|
||||||
id Int @id
|
id Int @id
|
||||||
name String
|
name String
|
||||||
color String
|
color String
|
||||||
capitalCityId Int? @map("capital_city_id")
|
capitalCityId Int? @map("capital_city_id")
|
||||||
gold Int @default(0)
|
chiefGeneralId Int? @map("chief_general_id")
|
||||||
rice Int @default(0)
|
gold Int @default(0)
|
||||||
tech Float @default(0)
|
rice Int @default(0)
|
||||||
level Int @default(0)
|
tech Float @default(0)
|
||||||
typeCode String @default("che_중립") @map("type_code")
|
level Int @default(0)
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
typeCode String @default("che_중립") @map("type_code")
|
||||||
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@map("nation")
|
@@map("nation")
|
||||||
}
|
}
|
||||||
|
|
||||||
model City {
|
model City {
|
||||||
id Int @id
|
id Int @id
|
||||||
name String
|
name String
|
||||||
level Int
|
level Int
|
||||||
nationId Int @default(0) @map("nation_id")
|
nationId Int @default(0) @map("nation_id")
|
||||||
supplyState Int @default(1) @map("supply_state")
|
supplyState Int @default(1) @map("supply_state")
|
||||||
frontState Int @default(0) @map("front_state")
|
frontState Int @default(0) @map("front_state")
|
||||||
population Int @map("pop")
|
population Int @map("pop")
|
||||||
populationMax Int @map("pop_max")
|
populationMax Int @map("pop_max")
|
||||||
agriculture Int @map("agri")
|
agriculture Int @map("agri")
|
||||||
agricultureMax Int @map("agri_max")
|
agricultureMax Int @map("agri_max")
|
||||||
commerce Int @map("comm")
|
commerce Int @map("comm")
|
||||||
commerceMax Int @map("comm_max")
|
commerceMax Int @map("comm_max")
|
||||||
security Int @map("secu")
|
security Int @map("secu")
|
||||||
securityMax Int @map("secu_max")
|
securityMax Int @map("secu_max")
|
||||||
trust Int @default(0)
|
trust Int @default(0)
|
||||||
trade Int @default(100)
|
trade Int @default(100)
|
||||||
defence Int @map("def")
|
defence Int @map("def")
|
||||||
defenceMax Int @map("def_max")
|
defenceMax Int @map("def_max")
|
||||||
wall Int @map("wall")
|
wall Int @map("wall")
|
||||||
wallMax Int @map("wall_max")
|
wallMax Int @map("wall_max")
|
||||||
region Int
|
region Int
|
||||||
conflict Json @default(dbgenerated("'{}'::jsonb"))
|
conflict Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@map("city")
|
@@map("city")
|
||||||
}
|
}
|
||||||
|
|
||||||
model General {
|
model General {
|
||||||
id Int @id
|
id Int @id
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
name String
|
name String
|
||||||
nationId Int @default(0) @map("nation_id")
|
nationId Int @default(0) @map("nation_id")
|
||||||
cityId Int @default(0) @map("city_id")
|
cityId Int @default(0) @map("city_id")
|
||||||
troopId Int @default(0) @map("troop_id")
|
troopId Int @default(0) @map("troop_id")
|
||||||
npcState Int @default(0) @map("npc_state")
|
npcState Int @default(0) @map("npc_state")
|
||||||
affinity Int?
|
affinity Int?
|
||||||
bornYear Int @default(180) @map("born_year")
|
bornYear Int @default(180) @map("born_year")
|
||||||
deadYear Int @default(300) @map("dead_year")
|
deadYear Int @default(300) @map("dead_year")
|
||||||
picture String?
|
picture String?
|
||||||
imageServer Int @default(0) @map("image_server")
|
imageServer Int @default(0) @map("image_server")
|
||||||
leadership Int @default(50)
|
leadership Int @default(50)
|
||||||
strength Int @default(50)
|
strength Int @default(50)
|
||||||
intel Int @default(50)
|
intel Int @default(50)
|
||||||
injury Int @default(0)
|
injury Int @default(0)
|
||||||
experience Int @default(0)
|
experience Int @default(0)
|
||||||
dedication Int @default(0)
|
dedication Int @default(0)
|
||||||
officerLevel Int @default(0) @map("officer_level")
|
officerLevel Int @default(0) @map("officer_level")
|
||||||
gold Int @default(1000)
|
gold Int @default(1000)
|
||||||
rice Int @default(1000)
|
rice Int @default(1000)
|
||||||
crew Int @default(0)
|
crew Int @default(0)
|
||||||
crewTypeId Int @default(0) @map("crew_type_id")
|
crewTypeId Int @default(0) @map("crew_type_id")
|
||||||
train Int @default(0)
|
train Int @default(0)
|
||||||
atmos Int @default(0)
|
atmos Int @default(0)
|
||||||
weaponCode String @default("None") @map("weapon_code")
|
weaponCode String @default("None") @map("weapon_code")
|
||||||
bookCode String @default("None") @map("book_code")
|
bookCode String @default("None") @map("book_code")
|
||||||
horseCode String @default("None") @map("horse_code")
|
horseCode String @default("None") @map("horse_code")
|
||||||
itemCode String @default("None") @map("item_code")
|
itemCode String @default("None") @map("item_code")
|
||||||
turnTime DateTime @map("turn_time")
|
turnTime DateTime @map("turn_time")
|
||||||
recentWarTime DateTime? @map("recent_war_time")
|
recentWarTime DateTime? @map("recent_war_time")
|
||||||
age Int @default(20)
|
age Int @default(20)
|
||||||
startAge Int @default(20) @map("start_age")
|
startAge Int @default(20) @map("start_age")
|
||||||
personalCode String @default("None") @map("personal_code")
|
personalCode String @default("None") @map("personal_code")
|
||||||
specialCode String @default("None") @map("special_code")
|
specialCode String @default("None") @map("special_code")
|
||||||
special2Code String @default("None") @map("special2_code")
|
special2Code String @default("None") @map("special2_code")
|
||||||
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
|
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
penalty Json @default(dbgenerated("'{}'::jsonb"))
|
penalty Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @map("updated_at")
|
updatedAt DateTime @default(now()) @map("updated_at")
|
||||||
|
|
||||||
@@map("general")
|
@@map("general")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GeneralAccessLog {
|
model GeneralAccessLog {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
generalId Int @unique @map("general_id")
|
generalId Int @unique @map("general_id")
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
lastRefresh DateTime? @map("last_refresh")
|
lastRefresh DateTime? @map("last_refresh")
|
||||||
refresh Int @default(0)
|
refresh Int @default(0)
|
||||||
refreshTotal Int @default(0) @map("refresh_total")
|
refreshTotal Int @default(0) @map("refresh_total")
|
||||||
refreshScore Int @default(0) @map("refresh_score")
|
refreshScore Int @default(0) @map("refresh_score")
|
||||||
refreshScoreTotal Int @default(0) @map("refresh_score_total")
|
refreshScoreTotal Int @default(0) @map("refresh_score_total")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("general_access_log")
|
@@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 {
|
model RankData {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
nationId Int @default(0) @map("nation_id")
|
nationId Int @default(0) @map("nation_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
type String @db.VarChar(20)
|
type String @db.VarChar(20)
|
||||||
value Int @default(0)
|
value Int @default(0)
|
||||||
|
|
||||||
@@unique([generalId, type])
|
@@unique([generalId, type])
|
||||||
@@index([type, value], name: "by_type")
|
@@index([type, value], name: "by_type")
|
||||||
@@index([nationId, type, value], name: "by_nation")
|
@@index([nationId, type, value], name: "by_nation")
|
||||||
@@map("rank_data")
|
@@map("rank_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
model HallOfFame {
|
model HallOfFame {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
serverId String @map("server_id")
|
serverId String @map("server_id")
|
||||||
season Int
|
season Int
|
||||||
scenario Int
|
scenario Int
|
||||||
generalNo Int @map("general_no")
|
generalNo Int @map("general_no")
|
||||||
type String @db.VarChar(20)
|
type String @db.VarChar(20)
|
||||||
value Float
|
value Float
|
||||||
owner String? @map("owner")
|
owner String? @map("owner")
|
||||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@unique([serverId, type, generalNo])
|
@@unique([serverId, type, generalNo])
|
||||||
@@unique([owner, serverId, type], name: "hall_owner")
|
@@unique([owner, serverId, type], name: "hall_owner")
|
||||||
@@index([serverId, type, value], name: "server_show")
|
@@index([serverId, type, value], name: "server_show")
|
||||||
@@index([season, scenario, type, value], name: "scenario")
|
@@index([season, scenario, type, value], name: "scenario")
|
||||||
@@map("hall")
|
@@map("hall")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GameHistory {
|
model GameHistory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
serverId String @map("server_id")
|
serverId String @map("server_id")
|
||||||
date DateTime
|
date DateTime
|
||||||
winnerNation Int? @map("winner_nation")
|
winnerNation Int? @map("winner_nation")
|
||||||
map String? @map("map")
|
map String? @map("map")
|
||||||
season Int
|
season Int
|
||||||
scenario Int
|
scenario Int
|
||||||
scenarioName String @map("scenario_name")
|
scenarioName String @map("scenario_name")
|
||||||
env Json @default(dbgenerated("'{}'::jsonb"))
|
env Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@unique([serverId])
|
@@unique([serverId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@map("ng_games")
|
@@map("ng_games")
|
||||||
}
|
}
|
||||||
|
|
||||||
model OldNation {
|
model OldNation {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
serverId String @map("server_id")
|
serverId String @map("server_id")
|
||||||
nation Int @default(0)
|
nation Int @default(0)
|
||||||
data Json @default(dbgenerated("'{}'::jsonb"))
|
data Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
|
|
||||||
@@unique([serverId, nation])
|
@@unique([serverId, nation])
|
||||||
@@index([serverId, nation], name: "by_server")
|
@@index([serverId, nation], name: "by_server")
|
||||||
@@map("ng_old_nations")
|
@@map("ng_old_nations")
|
||||||
}
|
}
|
||||||
|
|
||||||
model OldGeneral {
|
model OldGeneral {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
serverId String @map("server_id")
|
serverId String @map("server_id")
|
||||||
generalNo Int @map("general_no")
|
generalNo Int @map("general_no")
|
||||||
owner String? @map("owner")
|
owner String? @map("owner")
|
||||||
name String
|
name String
|
||||||
lastYearMonth Int @map("last_yearmonth")
|
lastYearMonth Int @map("last_yearmonth")
|
||||||
turnTime DateTime @map("turntime")
|
turnTime DateTime @map("turntime")
|
||||||
data Json @default(dbgenerated("'{}'::jsonb"))
|
data Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@unique([serverId, generalNo], name: "by_no")
|
@@unique([serverId, generalNo], name: "by_no")
|
||||||
@@index([serverId, name], name: "by_name")
|
@@index([serverId, name], name: "by_name")
|
||||||
@@index([owner, serverId], name: "owner")
|
@@index([owner, serverId], name: "owner")
|
||||||
@@map("ng_old_generals")
|
@@map("ng_old_generals")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Emperor {
|
model Emperor {
|
||||||
id Int @id @default(autoincrement()) @map("no")
|
id Int @id @default(autoincrement()) @map("no")
|
||||||
serverId String? @map("server_id")
|
serverId String? @map("server_id")
|
||||||
phase String? @default("")
|
phase String? @default("")
|
||||||
nationCount String? @map("nation_count")
|
nationCount String? @map("nation_count")
|
||||||
nationName String? @map("nation_name")
|
nationName String? @map("nation_name")
|
||||||
nationHist String? @map("nation_hist")
|
nationHist String? @map("nation_hist")
|
||||||
genCount String? @map("gen_count")
|
genCount String? @map("gen_count")
|
||||||
personalHist String? @map("personal_hist")
|
personalHist String? @map("personal_hist")
|
||||||
specialHist String? @map("special_hist")
|
specialHist String? @map("special_hist")
|
||||||
name String? @default("")
|
name String? @default("")
|
||||||
type String? @default("")
|
type String? @default("")
|
||||||
color String? @default("")
|
color String? @default("")
|
||||||
year Int? @default(0)
|
year Int? @default(0)
|
||||||
month Int? @default(0)
|
month Int? @default(0)
|
||||||
power Int? @default(0)
|
power Int? @default(0)
|
||||||
gennum Int? @default(0)
|
gennum Int? @default(0)
|
||||||
citynum Int? @default(0)
|
citynum Int? @default(0)
|
||||||
pop String? @default("0")
|
pop String? @default("0")
|
||||||
poprate String? @default("")
|
poprate String? @default("")
|
||||||
gold Int? @default(0)
|
gold Int? @default(0)
|
||||||
rice Int? @default(0)
|
rice Int? @default(0)
|
||||||
l12name String? @default("")
|
l12name String? @default("")
|
||||||
l12pic String? @default("")
|
l12pic String? @default("")
|
||||||
l11name String? @default("")
|
l11name String? @default("")
|
||||||
l11pic String? @default("")
|
l11pic String? @default("")
|
||||||
l10name String? @default("")
|
l10name String? @default("")
|
||||||
l10pic String? @default("")
|
l10pic String? @default("")
|
||||||
l9name String? @default("")
|
l9name String? @default("")
|
||||||
l9pic String? @default("")
|
l9pic String? @default("")
|
||||||
l8name String? @default("")
|
l8name String? @default("")
|
||||||
l8pic String? @default("")
|
l8pic String? @default("")
|
||||||
l7name String? @default("")
|
l7name String? @default("")
|
||||||
l7pic String? @default("")
|
l7pic String? @default("")
|
||||||
l6name String? @default("")
|
l6name String? @default("")
|
||||||
l6pic String? @default("")
|
l6pic String? @default("")
|
||||||
l5name String? @default("")
|
l5name String? @default("")
|
||||||
l5pic String? @default("")
|
l5pic String? @default("")
|
||||||
tiger String? @default("")
|
tiger String? @default("")
|
||||||
eagle String? @default("")
|
eagle String? @default("")
|
||||||
gen String? @default("")
|
gen String? @default("")
|
||||||
history Json @default(dbgenerated("'{}'::jsonb"))
|
history Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@map("emperior")
|
@@map("emperior")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Troop {
|
model Troop {
|
||||||
troopLeaderId Int @id @map("troop_leader")
|
troopLeaderId Int @id @map("troop_leader")
|
||||||
nationId Int @map("nation")
|
nationId Int @map("nation")
|
||||||
name String
|
name String
|
||||||
|
|
||||||
@@map("troop")
|
@@map("troop")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GeneralTurn {
|
model GeneralTurn {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
turnIdx Int @map("turn_idx")
|
turnIdx Int @map("turn_idx")
|
||||||
actionCode String @map("action_code")
|
actionCode String @map("action_code")
|
||||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([generalId, turnIdx])
|
@@unique([generalId, turnIdx])
|
||||||
@@map("general_turn")
|
@@map("general_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
model NationTurn {
|
model NationTurn {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
officerLevel Int @map("officer_level")
|
officerLevel Int @map("officer_level")
|
||||||
turnIdx Int @map("turn_idx")
|
turnIdx Int @map("turn_idx")
|
||||||
actionCode String @map("action_code")
|
actionCode String @map("action_code")
|
||||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([nationId, officerLevel, turnIdx])
|
@@unique([nationId, officerLevel, turnIdx])
|
||||||
@@map("nation_turn")
|
@@map("nation_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Diplomacy {
|
model Diplomacy {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
srcNationId Int @map("src_nation_id")
|
srcNationId Int @map("src_nation_id")
|
||||||
destNationId Int @map("dest_nation_id")
|
destNationId Int @map("dest_nation_id")
|
||||||
stateCode Int @map("state_code")
|
stateCode Int @map("state_code")
|
||||||
term Int @default(0)
|
term Int @default(0)
|
||||||
isDead Boolean @default(false) @map("is_dead")
|
isDead Boolean @default(false) @map("is_dead")
|
||||||
isShowing Boolean @default(true) @map("is_showing")
|
isShowing Boolean @default(true) @map("is_showing")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([srcNationId, destNationId])
|
@@unique([srcNationId, destNationId])
|
||||||
@@map("diplomacy")
|
@@map("diplomacy")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DiplomacyLetter {
|
model DiplomacyLetter {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
srcNationId Int @map("src_nation_id")
|
srcNationId Int @map("src_nation_id")
|
||||||
destNationId Int @map("dest_nation_id")
|
destNationId Int @map("dest_nation_id")
|
||||||
prevId Int? @map("prev_id")
|
prevId Int? @map("prev_id")
|
||||||
state DiplomacyLetterState @default(PROPOSED)
|
state DiplomacyLetterState @default(PROPOSED)
|
||||||
textBrief String @map("text_brief")
|
textBrief String @map("text_brief")
|
||||||
textDetail String @map("text_detail")
|
textDetail String @map("text_detail")
|
||||||
date DateTime @default(now()) @map("date")
|
date DateTime @default(now()) @map("date")
|
||||||
srcSignerId Int @map("src_signer")
|
srcSignerId Int @map("src_signer")
|
||||||
destSignerId Int? @map("dest_signer")
|
destSignerId Int? @map("dest_signer")
|
||||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|
||||||
@@index([srcNationId, destNationId])
|
@@index([srcNationId, destNationId])
|
||||||
@@index([destNationId, srcNationId])
|
@@index([destNationId, srcNationId])
|
||||||
@@index([state, date])
|
@@index([state, date])
|
||||||
@@map("diplomacy_letter")
|
@@map("diplomacy_letter")
|
||||||
}
|
}
|
||||||
|
|
||||||
model YearbookHistory {
|
model YearbookHistory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
profileName String @map("profile_name")
|
profileName String @map("profile_name")
|
||||||
year Int
|
year Int
|
||||||
month Int
|
month Int
|
||||||
map Json
|
map Json
|
||||||
nations Json
|
nations Json
|
||||||
hash String @default("")
|
hash String @default("")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([profileName, year, month])
|
@@unique([profileName, year, month])
|
||||||
@@index([profileName, year, month])
|
@@index([profileName, year, month])
|
||||||
@@map("yearbook_history")
|
@@map("yearbook_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Event {
|
model Event {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
targetCode String @map("target_code")
|
targetCode String @map("target_code")
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
condition Json @default(dbgenerated("'{}'::jsonb"))
|
condition Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
action Json @default(dbgenerated("'{}'::jsonb"))
|
action Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@map("event")
|
@@map("event")
|
||||||
}
|
}
|
||||||
|
|
||||||
model LogEntry {
|
model LogEntry {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
scope LogScope
|
scope LogScope
|
||||||
category LogCategory
|
category LogCategory
|
||||||
subType String? @map("sub_type")
|
subType String? @map("sub_type")
|
||||||
year Int
|
year Int
|
||||||
month Int
|
month Int
|
||||||
text String
|
text String
|
||||||
generalId Int? @map("general_id")
|
generalId Int? @map("general_id")
|
||||||
nationId Int? @map("nation_id")
|
nationId Int? @map("nation_id")
|
||||||
userId Int? @map("user_id")
|
userId Int? @map("user_id")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([scope, category, id])
|
@@index([scope, category, id])
|
||||||
@@index([generalId, category, id])
|
@@index([generalId, category, id])
|
||||||
@@index([nationId, category, id])
|
@@index([nationId, category, id])
|
||||||
@@index([userId, category, id])
|
@@index([userId, category, id])
|
||||||
@@map("log_entry")
|
@@map("log_entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ErrorLog {
|
model ErrorLog {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
category String
|
category String
|
||||||
source String? @map("source")
|
source String? @map("source")
|
||||||
message String
|
message String
|
||||||
trace String?
|
trace String?
|
||||||
context Json @default(dbgenerated("'{}'::jsonb"))
|
context Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([category, id])
|
@@index([category, id])
|
||||||
@@map("error_log")
|
@@map("error_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
model InheritancePoint {
|
model InheritancePoint {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
key String
|
key String
|
||||||
value Float @default(0)
|
value Float @default(0)
|
||||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@unique([userId, key])
|
@@unique([userId, key])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("inheritance_point")
|
@@map("inheritance_point")
|
||||||
}
|
}
|
||||||
|
|
||||||
model InheritanceLog {
|
model InheritanceLog {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
year Int
|
year Int
|
||||||
month Int
|
month Int
|
||||||
text String
|
text String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([userId, id])
|
@@index([userId, id])
|
||||||
@@map("inheritance_log")
|
@@map("inheritance_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
model InheritanceResult {
|
model InheritanceResult {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
serverId String @map("server_id")
|
serverId String @map("server_id")
|
||||||
owner String @map("owner")
|
owner String @map("owner")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
year Int
|
year Int
|
||||||
month Int
|
month Int
|
||||||
value Json @default(dbgenerated("'{}'::jsonb"))
|
value Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([serverId, owner])
|
@@index([serverId, owner])
|
||||||
@@map("inheritance_result")
|
@@map("inheritance_result")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Auction {
|
model Auction {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type AuctionType
|
type AuctionType
|
||||||
targetCode String? @map("target_code")
|
targetCode String? @map("target_code")
|
||||||
hostGeneralId Int @map("host_general_id")
|
hostGeneralId Int @map("host_general_id")
|
||||||
hostName String? @map("host_name")
|
hostName String? @map("host_name")
|
||||||
detail Json @default(dbgenerated("'{}'::jsonb"))
|
detail Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
status AuctionStatus @default(OPEN)
|
status AuctionStatus @default(OPEN)
|
||||||
closeAt DateTime @map("close_at")
|
closeAt DateTime @map("close_at")
|
||||||
latestEventId String @default("") @map("latest_event_id")
|
latestEventId String @default("") @map("latest_event_id")
|
||||||
latestEventAt DateTime @default(now()) @map("latest_event_at")
|
latestEventAt DateTime @default(now()) @map("latest_event_at")
|
||||||
finalizingAt DateTime? @map("finalizing_at")
|
finalizingAt DateTime? @map("finalizing_at")
|
||||||
finishedAt DateTime? @map("finished_at")
|
finishedAt DateTime? @map("finished_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
bids AuctionBid[]
|
bids AuctionBid[]
|
||||||
|
|
||||||
@@index([status, closeAt])
|
@@index([status, closeAt])
|
||||||
@@map("auction")
|
@@map("auction")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuctionBid {
|
model AuctionBid {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
auctionId Int @map("auction_id")
|
auctionId Int @map("auction_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
amount Int
|
amount Int
|
||||||
eventId String @map("event_id")
|
eventId String @map("event_id")
|
||||||
eventAt DateTime @map("event_at")
|
eventAt DateTime @map("event_at")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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, amount])
|
||||||
@@index([auctionId, eventAt])
|
@@index([auctionId, eventAt])
|
||||||
@@map("auction_bid")
|
@@map("auction_bid")
|
||||||
}
|
}
|
||||||
|
|
||||||
model InheritanceUserState {
|
model InheritanceUserState {
|
||||||
userId String @id @map("user_id")
|
userId String @id @map("user_id")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("inheritance_user_state")
|
@@map("inheritance_user_state")
|
||||||
}
|
}
|
||||||
|
|
||||||
model BoardPost {
|
model BoardPost {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
isSecret Boolean @default(false) @map("is_secret")
|
isSecret Boolean @default(false) @map("is_secret")
|
||||||
authorGeneralId Int @map("author_general_id")
|
authorGeneralId Int @map("author_general_id")
|
||||||
authorName String @map("author_name")
|
authorName String @map("author_name")
|
||||||
title String
|
title String
|
||||||
contentHtml String @map("content_html")
|
contentHtml String @map("content_html")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
comments BoardComment[]
|
comments BoardComment[]
|
||||||
|
|
||||||
@@index([nationId, isSecret, createdAt])
|
@@index([nationId, isSecret, createdAt])
|
||||||
@@map("board_post")
|
@@map("board_post")
|
||||||
}
|
}
|
||||||
|
|
||||||
model BoardComment {
|
model BoardComment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
postId Int @map("post_id")
|
postId Int @map("post_id")
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
isSecret Boolean @default(false) @map("is_secret")
|
isSecret Boolean @default(false) @map("is_secret")
|
||||||
authorGeneralId Int @map("author_general_id")
|
authorGeneralId Int @map("author_general_id")
|
||||||
authorName String @map("author_name")
|
authorName String @map("author_name")
|
||||||
contentText String @map("content_text")
|
contentText String @map("content_text")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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])
|
@@index([postId, createdAt])
|
||||||
@@map("board_comment")
|
@@map("board_comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
model VotePoll {
|
model VotePoll {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
body String @default("")
|
body String @default("")
|
||||||
options Json
|
options Json
|
||||||
multipleOptions Int @default(1) @map("multiple_options")
|
multipleOptions Int @default(1) @map("multiple_options")
|
||||||
revealMode String @map("reveal_mode")
|
revealMode String @map("reveal_mode")
|
||||||
openerGeneralId Int @map("opener_general_id")
|
openerGeneralId Int @map("opener_general_id")
|
||||||
openerName String @map("opener_name")
|
openerName String @map("opener_name")
|
||||||
startAt DateTime @default(now()) @map("start_at")
|
startAt DateTime @default(now()) @map("start_at")
|
||||||
endAt DateTime? @map("end_at")
|
endAt DateTime? @map("end_at")
|
||||||
closedAt DateTime? @map("closed_at")
|
closedAt DateTime? @map("closed_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
comments VoteComment[]
|
comments VoteComment[]
|
||||||
|
|
||||||
@@map("vote_poll")
|
@@map("vote_poll")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vote {
|
model Vote {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
voteId Int @map("vote_id")
|
voteId Int @map("vote_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
selection Json
|
selection Json
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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])
|
@@unique([voteId, generalId])
|
||||||
@@index([voteId])
|
@@index([voteId])
|
||||||
@@map("vote")
|
@@map("vote")
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoteComment {
|
model VoteComment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
voteId Int @map("vote_id")
|
voteId Int @map("vote_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
generalName String @map("general_name")
|
generalName String @map("general_name")
|
||||||
nationName String @map("nation_name")
|
nationName String @map("nation_name")
|
||||||
text String
|
text String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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])
|
@@index([voteId, createdAt])
|
||||||
@@map("vote_comment")
|
@@map("vote_comment")
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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);
|
||||||
@@ -1,84 +1,89 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "./generated/gateway"
|
output = "./generated/gateway"
|
||||||
engineType = "binary"
|
engineType = "binary"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OAuthType {
|
enum OAuthType {
|
||||||
NONE
|
NONE
|
||||||
KAKAO
|
KAKAO
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GatewayProfileStatus {
|
enum GatewayProfileStatus {
|
||||||
RESERVED
|
RESERVED
|
||||||
PREOPEN
|
PREOPEN
|
||||||
RUNNING
|
RUNNING
|
||||||
PAUSED
|
PAUSED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
STOPPED
|
STOPPED
|
||||||
DISABLED
|
DISABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GatewayBuildStatus {
|
enum GatewayBuildStatus {
|
||||||
IDLE
|
IDLE
|
||||||
QUEUED
|
QUEUED
|
||||||
RUNNING
|
RUNNING
|
||||||
FAILED
|
FAILED
|
||||||
SUCCEEDED
|
SUCCEEDED
|
||||||
}
|
}
|
||||||
|
|
||||||
model AppUser {
|
model AppUser {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
loginId String @unique @map("login_id")
|
loginId String @unique @map("login_id")
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
passwordSalt String @map("password_salt")
|
passwordSalt String @map("password_salt")
|
||||||
roles Json @default(dbgenerated("'[]'::jsonb"))
|
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||||
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
oauthType OAuthType @default(NONE) @map("oauth_type")
|
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||||
oauthId String? @unique @map("oauth_id")
|
oauthId String? @unique @map("oauth_id")
|
||||||
email String? @unique
|
email String? @unique
|
||||||
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
picture String @default("default.jpg")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
imageServer Int @default(0) @map("image_server")
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
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 {
|
model GatewayProfile {
|
||||||
profileName String @id @map("profile_name")
|
profileName String @id @map("profile_name")
|
||||||
profile String
|
profile String
|
||||||
scenario String
|
scenario String
|
||||||
apiPort Int @map("api_port")
|
apiPort Int @map("api_port")
|
||||||
status GatewayProfileStatus
|
status GatewayProfileStatus
|
||||||
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
|
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
|
||||||
buildCommitSha String? @map("build_commit_sha")
|
buildCommitSha String? @map("build_commit_sha")
|
||||||
buildWorkspace String? @map("build_workspace")
|
buildWorkspace String? @map("build_workspace")
|
||||||
buildLastUsedAt DateTime? @map("build_last_used_at")
|
buildLastUsedAt DateTime? @map("build_last_used_at")
|
||||||
preopenAt DateTime? @map("preopen_at")
|
preopenAt DateTime? @map("preopen_at")
|
||||||
openAt DateTime? @map("open_at")
|
openAt DateTime? @map("open_at")
|
||||||
scheduledStartAt DateTime? @map("scheduled_start_at")
|
scheduledStartAt DateTime? @map("scheduled_start_at")
|
||||||
buildRequestedAt DateTime? @map("build_requested_at")
|
buildRequestedAt DateTime? @map("build_requested_at")
|
||||||
buildStartedAt DateTime? @map("build_started_at")
|
buildStartedAt DateTime? @map("build_started_at")
|
||||||
buildCompletedAt DateTime? @map("build_completed_at")
|
buildCompletedAt DateTime? @map("build_completed_at")
|
||||||
buildError String? @map("build_error")
|
buildError String? @map("build_error")
|
||||||
lastError String? @map("last_error")
|
lastError String? @map("last_error")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@unique([profile, scenario])
|
@@unique([profile, scenario])
|
||||||
@@map("gateway_profile")
|
@@map("gateway_profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SystemSetting {
|
model SystemSetting {
|
||||||
id Int @id @default(1) @map("no")
|
id Int @id @default(1) @map("no")
|
||||||
notice String @default("") @map("notice")
|
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;
|
||||||
@@ -7,6 +7,8 @@ export interface DatabaseClient {
|
|||||||
worldState: GamePrisma.WorldStateDelegate;
|
worldState: GamePrisma.WorldStateDelegate;
|
||||||
general: GamePrisma.GeneralDelegate;
|
general: GamePrisma.GeneralDelegate;
|
||||||
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
||||||
|
messageReadState: GamePrisma.MessageReadStateDelegate;
|
||||||
|
message: GamePrisma.MessageDelegate;
|
||||||
city: GamePrisma.CityDelegate;
|
city: GamePrisma.CityDelegate;
|
||||||
nation: GamePrisma.NationDelegate;
|
nation: GamePrisma.NationDelegate;
|
||||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface TurnEngineNationRow {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
capitalCityId: number | null;
|
capitalCityId: number | null;
|
||||||
|
chiefGeneralId: number | null;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
tech: number;
|
tech: number;
|
||||||
@@ -287,6 +288,7 @@ export interface TurnEngineNationCreateManyInput {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
capitalCityId: number | null;
|
capitalCityId: number | null;
|
||||||
|
chiefGeneralId: number | null;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
tech: number;
|
tech: number;
|
||||||
|
|||||||
Generated
+6
@@ -233,6 +233,9 @@ importers:
|
|||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^11.2.0
|
specifier: ^11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
|
'@fastify/static':
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.2.0
|
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)
|
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:
|
redis:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.34.4
|
||||||
|
version: 0.34.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ describe('auction integration flow', () => {
|
|||||||
|
|
||||||
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||||
sessionToken: adminSessionRef.value ?? '',
|
sessionToken: adminSessionRef.value ?? '',
|
||||||
profile: 'che:2',
|
profile: 'che:908',
|
||||||
});
|
});
|
||||||
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
|
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
|
||||||
gatewayToken: adminGatewayToken.gameToken,
|
gatewayToken: adminGatewayToken.gameToken,
|
||||||
|
|||||||
Reference in New Issue
Block a user